Imported from AndrewRedican/hyperfrontend (
.claude/skills/koi-pond-demo/SKILL.md). Install upstream withnpx skills add AndrewRedican/hyperfrontend --skill koi-pond-demo. Copyright stays with the author.
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.tsv0.8.0 (pond host ↔ each koi): host sendspond/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 emitsoutline/depth-request/ripple-request/settled.identitycarries a requiredinstanceordinal (0 = canonical fish, 1..n = duplicates) and the host-dealtseed;outlineoptionally carriespath(up to 20 points of predicted advancement, capped by the producer).outline+neighborsare schema-less (hot path); the outline ordinarily carriesintent: KoiIntent {kind travel|avoid|depth-change, heading, gain, target, direction?, reachPx, clearancePx}, suppressed while held; while HELD it instead carriescard: 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). Outerhost/koi-pond.contract.tsv0.2.0 (gallery ↔ pond): gallery sendsset-scene(card/full) /disturb; pond emitsshoal/sequence-complete/close-request {source: button|escape}. Escape inside the pond releases a held koi first, else emitsclose-request(escape-close.ts+featureUi.requestClose(source), latch re-armed byset-scene) — the host page can never hear keydown once focus is in the pond's document. The pond re-emitsshoalevery 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 opensfullin the same tick; hosted holds the water empty until the firstset-scene, a non-embeddedpresentation (dialog/popup/host-opened tab reads as full immediately), orSCENE_FALLBACK_MS(1s). An instance never morphs: a contradictingset-scenelater is diagnosedscene:ignoredand changes nothing.hostedis the SDK handle's synchronous flag (features ≥0.8.0); the pond uses no URL params and nowindow.parent. Never describehosted: falseas "standalone" — every DisplayMode is hosted; say "top-level" or "a direct visit". - Two profiles.
openProfileinpond.ts: card = one koi,KOI_FRAMEWORKS[bootHour % 8]ordinal 0, world fromdescribePondForFrame(root.clientWidth, root.clientHeight)(the card's own frame, not the screen through a keyhole), heldrestingfrom open andplaced 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 isopeningShoal(root.clientWidth, root.clientHeight, device.cap)inruntime/device-tier.ts— bands on the frame's geometric meansqrt(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 todevice.cap. The centreplaceis not decoration:entryStationlays 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.
KoiInstanceIdis the branded stringframework:ordinal(host/src/scene/instance-id.ts:koiInstanceId,instanceFramework,instanceOrdinal,nextOrdinal); the wire never sees it, the host converts at the channel boundary. Layers carrydata-fish(framework) anddata-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'sadopt()rebuilds profile, brain and renderer around a dealt seed that differs from its canonical assumption. - The dynamic shoal.
PondSceneHandle:addKoi(framework)→ new id ornullat the cap (onDiagnostic(null,'shoal:refused','<tier>-tier device seats <cap>'));removeKoi(id)→falsefor 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.tscreateShoalPanel,nav.koi-shoalz-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. UnderPILL_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/heightis the virtual pond (fromwindow.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, soruntime.setPondre-stations the koi whenever the announced world's dimensions differ from the one it guessed (entryStationin 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:0layer per koi. z-index is the depth model (depthZIndex, seven levels): floor 0, fish z1..7, water z100,#interactionsz120, 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 inpond.ts, driven byopen/close/error/watchdogstatus) is the single source forsetLayerPresent, the panel and the shoal count, because a frame the browser has killed keeps painting an opaque crash placeholder.scene/resurrection.tsre-opens a session whoseerror: unresponsiveverdict 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 feedsrelay.latestoutlines toscene/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 bandintent.clearancePxeither side of the heading runningintent.reachPxahead, which is exactly the pairresolveEncountergates a crossing on (CPA withinENCOUNTER_HORIZON_S2.4s and insidemax(len)*0.55 + both girths). Hung from the reported NOSE (the point the narrow phase judges from) and openedFIELD_STANDOFF_BODIES0.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_SOURCE0.74 (FIELD_ALPHA0.26 →FIELD_SHOULDER0.55 atFIELD_SHOULDER_SHARE0.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 ownpath(the host predicts nothing), spacingPEARL_SPACING_BODIES0.1,PEARL_MAX10 alight, 5–6px, alpha 0.8 at the nose → 0.1 at the horizon; a pearl never moves once placed —advanceTraceconsumes 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_BODIES0.028, clamped 3.2–8.5px) and orbitingheadCentreatbeamOf(girth) + 0.12 body + CARET_ORBIT_BODIES 0.08 body(~0.30–0.33 body, phenotype-derived), slidingCARET_SLIDE_RAD_S2 rad/s towardintent.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 whenintent.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 silhouettesetOutline(0.35), cursor pointer/grab — no card); press = hold (pause; FULL silhouettesetOutline(1)+ card until release; picking a DIFFERENT koi releases the held one first); drag past slop (6px/12px touch) = carry (placestreamed 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-hullkoi-outline-skin/koi-outline-finsinlib/src/three/koi.tssharing the live spine uniforms) — NEVER reintroduce a host-drawn rectangle. Mobile:touch-action: manipulationon#pond; a non-passivetouchmovelistener preventDefaults ONLY while a fish press is active. - Rendering. Each fish owns its own transparent
WebGLRendererbut renders ONLY its own frame box:koiFrameBox(lib) → square canvas ~1.7 body lengths, slid by CSS transform, camera narrowed viaPondView.frame()(setViewOffset), buffer sized byfitPondRenderer(DPR≤2, 1280px cap), hidden + skipped entirely when outside the view. NEVER go back to viewport-sized fish canvases, and for the same reasoncreatePondRendererasks forantialiasonly below DPR 2. Release GL on hidden:sleep {paused:true}disposes the renderer after the loop stops; wake rebuilds it, staggeredWAKE_STAGGER_MS60 per ordinal so a returning shoal never creates every context in one frame — so every renderer factory must be re-invokable and itsdisposemust clear everything it mounted. The koi is anchored byPondView.placeKoi(nose → pond point), never byplace. 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 atMAX_CANVAS_DPR2 (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 onwebglcontextrestored(buildScene, everything belonged to the dead context, and resetsizedTo);onLosttells the pond, which waitsWATER_RESTORE_GRACE_MS1500 and then callsreseatSurface(stage)— the canvas ELEMENT must go, sincegetContexton a lost-and-unrestored canvas returns the same dead context. Never checklost()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 fromPOND_VIEW(tilt 10°/fov 26°/exposure 1.15) viacreatePondView. - 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:chooseTurnTierpicks the least effort predicted to clear (evasionTierssubtle π/10 gain 0.55, normal π/5 gain 1, hard π/3 gain 1.6) permitted bytierWindowS(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 fireonDecisionon each fresh anchor;flankCrowdingpicks the side from the water, and at|crowding| ≤ sideEvidence(0.02) a seeded draw breaks right withrightBias0.7 so head-on pairs mutually avoid;turnBrake0.35 charges the commanded manoeuvre against cruise (never double-charged withturnSpeedTax). Turn dynamics: the heading's rate is a woundturnVelocityunderTURN_ACCEL2.2 rad/s² (never a step), ceilings fromturnRate {0.35,0.8}×gain taxed by speed over cruise,TURN_APPROACHramps every turn out; speed capped atMAX_SPEED_BL_S3.4 withACCEL_LIMIT_BL_S22.6. 3D side:turnBendsaturates at 1.4 rad centred 0.4 (torso, NOT the 0.32 shoulder that read as a broken neck),bodyFlexibilitykeeps the skull near-rigid, the amplitude surge term is clamped (also why every renderer refresheslastSpeedin its offscreen-skip branch). - A koi opened on its own.
lib/src/solo/(solo-page.ts+framework-mark.ts):createKoiRuntimemounts it ONLY when the app passeshosted: falseexplicitly (silence reads as hosted — painted water inside a frame blanks every koi below it), andsetPond/disposetake it straight back down. It prepends a.koi-solo-skytobody(DOM order, no z-index, so the koi's own canvas paints over it), appends a.koi-solopanel, and injects its own<style>(lit ships a differentfish.css, so the sheet cannot be relied on). Contents come from what the lib already knows:profile.label,palette.pattern,palette.accenttinting a per-framework simplified mark, aFRAMEWORK_NOTESsentence on how that framework mounts THIS app,KOI_CONTRACT_VERSION, and links toFRAMEWORK_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 onApplicationRef.destroy(). Never hand it the app root: the runtime disposes and rebuilds the renderer on every wake and on everyadoptwith a dealt seed, so#appleft 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.tsgives it adisplay: contentsdiv it makes itself. - Predicted path.
motion.predictPath(steps, dtStep)integrates withstepFlight, 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) mountscomponents/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 recordcores=… memory=…GB|unreported tier=… cap=…, and session/roster/visibility log lines through thePondHooks.onDiagnosticseam, 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/removedname the instance and the roster size (4 of 4 koi);shoal:refusednames the tier. The panel title readsvitals · visible|hiddenfrom the visibility watch. While armed the scene handle also sits onwindow.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 callsresurrection.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 keepsprotocol: 'v1'. - Embedding. Each service ships
public/hf-serve.config.jsoncarryingframe-ancestorsheader rules forhf serve.frame-ancestorsis 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 COOPsame-origin+ COEPrequire-corp+ CORPcross-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 devapplies no header rules; to see isolation locally,hf serve --rootthe built site.
Commands (from repo root)
# 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
# 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):
// 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.jsonmetadata.deploy: servicehyperfrontend-demo-koi-pond, originhttps://demo-koi-pond-production.up.railway.app,publishDir: dist/apps/demos/koi-pond/site. Build root must be at/aboveapps/demos/koi-pondso../vendoris in context; host must not SPA-rewrite. Origin also inhost/feature.config.tsurland docs-sitedemo-manifest.tsfallback — all three move together. - Gallery shell (only needed if the outer contract or
feature.config.tschanges — not for host scene edits):
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)
demo-koi-lib:refreshthen:verify(shells bake the contract from the installed tarball).- Bump every fish
feature.config.tsversionto match the contract. demo-koi-pond:refresh-fish-shells— packs all eight and copies tohost/vendor/, but never prunes.- Hand-delete the stale-version
.tgzfrom bothhost/vendor/anddist/apps/demos/koi-pond/fish-shell/*/. - Re-run
node host/scripts/install-vendored-shells.mjs(the explicit-path install is the only invocation that re-reads a same-name tarball). - Commit tarballs,
package.jsonand 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.htmlto 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; thehostedflag 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 withdebug.port: 4290inhf-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-libanddemo-koi-pondonly. The eightfish-*apps carry no vitest, notesttarget, 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 throughscene/__tests__/overlay-recorder.ts, a recording 2D context. - Devcontainer: ten
npm installs atparallel:1; fullnx lint docs-sitecan SIGKILL — lint targeted file lists (npx eslint <files>).
Checklist
- Edited
lib/src? →demo-koi-lib:refreshthen: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, neverserve -s