git:20260918.d661546 to git:20260929.4aa0939
12 added, 8 removed. Audit A to A.
# DOX — packages/deck3d/src/runtime
Browser runtime, bundled by esbuild into `dist/runtime.js` and inlined into `deck.html`. See change: add-deck3d-presentation-package.
| File | Purpose |
|------|---------|
| `AGENTS.md` | This file. |
- | `index.ts` | Bundle entry + engine. Reads `window.__DECK` (merged IR) + `window.__DECK_FONT` (base64 TTF) + `window.__DECK_PROPS` (prop GLBs); builds one `THREE.Group` per slide (title/slab/diagram/background/props), rail camera + dolly/swing/fade transitions, hash deep-link (1-based; `#0`/out-of-range → slide 1), rAF loop with `setTimeout` fallback on `document.hidden`. Exposes `window.__deck3d` (`gotoSlide`, `setTime`, `ready`, `measure`, `peaks`, `effects` incl. `budget`, `debug.titleGlyphs`/`liftedMessage`, `current`). Deterministic clock when `setTime` is used. |
- | `types.ts` | `RuntimeDeck`/`SlideConfig`/`Deck3dApi`/`Measurement` + `window.__DECK`/`__DECK_FONT`/`__DECK_PROPS`/`__deck3d` global augmentation. `effects()` carries the per-slide `budget` (`{sum,limit,warning?}`). |
- | `palette.ts` | `PALETTES` (blackbelt/zenit/dapp) + `resolvePalette` (mode-aware; `custom` derives bg/text from the card colour). |
+ | `index.ts` | Bundle entry + engine. `poseAt(t)` poses+draws, `applyTime(t)` = poseAt + PIN the clock; boot calls `poseAt(0)` — calling the pinning variant at boot shipped every deck frozen at t=0 (camera transitions still ran, nothing animated). `debug.motion()` fingerprints animated transforms; `debug.backdropLeaks()` lists fx subtrees that escaped onto the content layer (`check` rule `fx-content-layer`). Reads `window.__DECK` (merged IR) + `window.__DECK_FONT` (base64 TTF) + `window.__DECK_PROPS` (prop GLBs) + `window.__DECK_LOCAL_FX` (per-deck effect `{card,src}`); builds one `THREE.Group` per slide (title/slab/diagram/background/props), rail camera + dolly/swing/fade transitions, hash deep-link (1-based; `#0`/out-of-range → slide 1), live navigation via `navTo` (keydown `ArrowRight`/`ArrowDown`/`PageDown`/`Space` forward, `ArrowLeft`/`ArrowUp`/`PageUp` back, `Home`/`End`; left-click `pointerup` advances; `hashchange` follows; clamped at both ends, mirrors index into `location.hash`), rAF loop with `setTimeout` fallback on `document.hidden`. Exposes `window.__deck3d` (`gotoSlide`, `setTime`, `ready`, `measure`, `peaks`, `effects` incl. `budget`, `debug.titleGlyphs`/`liftedMessage`, `current`). `effects()` also returns `errors[]` (`{slide,effectId,phase}`). Builds the configurator via `createHud` and suppresses navigation + the `C` toggle while a panel input has focus or a click lands inside the panel. `fxSeedFor(slideId)` seeds each effect's `ctx.rng`. Deterministic clock when `setTime` is used. See change: deck3d-cinematic-worlds. `applySlide` (configurator) REBUILDS the current slide via `buildSlideGroup(..., patch)` — colours/labels/anchor/background FX are baked at build time, so re-running `applyLook` alone reached only the lights; ~10-21 ms measured on the heaviest real slide. `disposeBuild` frees the discarded build but SKIPS the props subtree (prop clones share geometry/material by reference with the shared template). `debug.look()` reports live bg/fog/rim/title/camZ for configurator tests. `billboardLabels()` points every diagram label at the camera each frame: `globe`/`loop`/`swarm`/`orbit-cluster` spin a group CONTAINING their captions, so a live clock turned them edge-on then backwards. `debug.labelFacing()` measures it — `measure()` structurally cannot (its projection is rotation-invariant for rails). `debug.look()` also returns `cam`/`camTarget`/`anim`, plus `keyTarget`/`rimPos` (the lights must track the slide). `debug.shadows()` reports the shadow pipeline as the RENDERER sees it (map enabled/autoUpdate, key frustum, caster/receiver labels with their LAYER MASKS) / the `castShadow` flags scattered through the builders say nothing about whether a shadow reaches the frame, and the mask is what exposed the backdrop-pass empty-map bug. See change: deck3d-cinematic-worlds. `disposeLocalFx` DETACHES handle nodes (dispose alone leaks them; revival then doubled the backdrop); `reviveLocalFx(i)` re-instantiates a revisited slide's `local:` fx, skipping ones that threw on create. `fxSetEdits`/`effectsOf` overlay the configurator's effect LIST on the authored one (every read of `slide.effects` goes through it, so an add/remove reaches background, `local:`, post stack and budget); `applyEffects` rebuilds the slide rather than flipping `background.visible`. `fxParamEdits`/`paramsFor` overlay live configurator param edits on authored `ref.params`. `syncPost()` hands the current slide's `post` cards (+ params) to `rig.setPost` from `snapTo`, `goTo`, `rebuildSlide`, `applyEffectParams`; `partsOf(i, name)` resolves `userData.part` tags (`title` group, diagram `holder`, props group) for selective passes; `debug.post()` probes the stack. The old `wantsBloom` boot hack is gone — a listed `bloom` card is honoured per slide. `applyEffectParams(id, patch)` rebuilds ONE effect in place. Probes: `debug.localFx()` (live handles, scoped), `debug.sceneNodes()` (orphan detector), `debug.motion()` now includes instance matrices. See change: deck3d-cinematic-worlds. Camera smoothing (0.2 lerp) applies ONLY when `anim === null`; during a fly the camera tracks `camState` exactly, or a finished transition leaves the view parked over the previous slide. See change: deck3d-cinematic-worlds. `pendingDispose` holds the outgoing slide across a fly: its local fx are reclaimed when the transition LANDS, not when `goTo` is called, and keep ticking meanwhile (a cut still disposes immediately). See change: deck3d-cinematic-worlds. | Background effects take an engine-level `lift` param (number, -6..8, default 0) applied in `backgroundFromEffects` — the one place both the build and the live param-edit path pass through — raising the fx box off the floor (y -2.6); It does NOT close the horizon strip — measured on slide 15, the 4px background band stays at y=227 for lift 0/1.5/2.5/3.5 because it is fixed in SCREEN space (far floor at the horizon), and lifting only brightens the plates above it (row jump 100 -> 138). `lift` is a composition knob, not a horizon fix; `effectParams()` advertises it for every `kind: "background"` card so the panel renders a control with no per-fx code. See change: deck3d-cinematic-worlds. `settle(active)` tells each slide whether it is the settled one, calling `background.setActive(bool)`; driven from `snapTo` and at transition start/end so time-based media (`video-screen`) freezes while the camera flies and never plays off-screen — a pinned `setTime` therefore renders a stable frame. See change: deck3d-cinematic-worlds. |
+ | `types.ts` | `RuntimeDeck`/`SlideConfig`/`Deck3dApi`/`Measurement` + `window.__DECK`/`__DECK_FONT`/`__DECK_PROPS`/`__deck3d` global augmentation. `effects()` carries the per-slide `budget` (`{sum,limit,warning?}`) and `errors[]` for local-effect failures. See change: deck3d-cinematic-worlds. `Deck3dApi.debug.look()` returns `{bg,fog,rim,title,camZ}`. |
+ | `palette.ts` | `PALETTES` (blackbelt/zenit/dapp/midnight/ember/arctic/forest/mono/neon) + `resolvePalette` (mode-aware; `custom` derives bg/text from the card colour). Every named palette meets WCAG AA for text-on-bg AND text-on-card in both modes, asserted by the corpus test. See change: deck3d-cinematic-worlds. `mixPalette(a,b,k)` blends two palettes channel-wise for the transition morph. See change: deck3d-cinematic-worlds. |
| `quality.ts` | `qualityProfile(low|medium|high)` → `{ bloom, mirror, shadowMapSize, particles }` (D6). |
- | `materials.ts` | `diagramMaterial` (glass/metal/matte; accent vs second) + `titleMaterial`. |
- | `text.ts` | `loadFont` (opentype parse from base64), `ttfShapes`, `wrap`, `buildTitle` (extruded, reports missing glyphs), `buildLabel` (canvas plane, `P.bg` outline, `toneMapped:false`), `bulletTexture`. Real TTF only — no corrupt `typeface.json` fallback. |
+ | `materials.ts` | `diagramMaterial` (glass/metal/matte; accent vs second), `titleMaterial`, `titleEdgeMaterial`/`titleEdgeColour` + `EDGE_CONTRAST_FLOOR` (2.2). Contour is UNLIT (`MeshBasicMaterial`) but TONE-MAPPED and dimmed under `BLOOM_CEILING` (0.8 linear) — side walls sit near-perpendicular to the camera, exactly where a lit material goes black. Colour = palette `text`, LIGHTNESS pushed away from the face when it misses the floor (hue/saturation preserved; `forest/light` text is 1.46 vs accent), then dimmed: three skips in-shader tone mapping when rendering to a target, so a white contour reaches the bloom pass at 1.0 and halos. Contrast floor is checked on the DIMMED colour. See change: deck3d-cinematic-worlds. |
+ | `text.ts` | `loadFont` (opentype parse from base64), `ttfShapes`, `wrap`, `buildTitle` (extruded, reports missing glyphs; `mat` may be `[face, edge]` — `ExtrudeGeometry` emits one group PAIR per glyph shape, material 0 = faces, 1 = walls+bevel, which is how `defaults.titleEdge: "contrast"` draws a per-character contour), `buildLabel` (canvas plane, `P.bg` outline, `toneMapped:false`), `bulletTexture`. Real TTF only — no corrupt `typeface.json` fallback. Diagram captions draw with `depthTest: false` + `renderOrder: 12` — a caption is an annotation, so plinth/globe geometry must not swallow it. Every `buildLabel` caller is a diagram label (builders.ts only). See change: deck3d-cinematic-worlds. |
+ | `measure.ts` | `projectRect(object, camera, w, h)` → screen rect, used by `measure()` and therefore every `check` fit/overlap/occlusion rule. Takes the bounds in the OBJECT'S OWN frame then applies `matrixWorld`; a world-axis-aligned `Box3` inflated up to 1.5× on a slide turned by `orbit`/`helix` and raised false `fit` findings. See change: deck3d-cinematic-worlds. |
| `node-geometry.ts` | `nodeGeometry(shape, w, h)` → three.js primitive (rect/stadium/round/circle/doublecircle/diamond/hexagon/cylinder). |
| `props.ts` | glTF props in the runtime: `loadPropModels` (GLTFLoader + meshopt from `__DECK_PROPS`), `createPropMaterials` (one shared metal/matte family per deck), `applyProps` by role (`hero`/`illustration`/`ambient`/`node:<id>`), `Box3` normalise + ground + face camera, `restyle:palette`, anim presets as `t` functions. |
- | `builders.ts` | `buildDiagram` dispatch. `buildFlowchart` (layout → staircase, tubes, arrow-heads, group plates, node overrides), `buildSequence` (actor slabs, time staircase, `1.1 s` message pulse), `buildBrain`/`buildLoop`/`buildSwarm`. Returns `{ g, tick, nodes, labels }`. |
+ | `builders.ts` | `buildDiagram` dispatch. `buildFlowchart` (layout → staircase, tubes, arrow-heads, group plates, node overrides), `buildSequence` (actor slabs, time staircase, `1.1 s` message pulse), and NINE built topologies driven by `diagram.data`: `brain`/`loop`/`swarm` (captioned members pinned to an even `ringPoint` so captions never overlap) plus `bars` (shallow columns, height ∝ value, zero → stub, captions in front of the plinth), `funnel`, `timeline-rail` (captions alternate above/below the rail), `globe`, `orbit-cluster`, `stack`. Captions clip at `CAPTION_MAX` 22 chars. All return `{ g, tick, nodes, labels }` so `check` measures them. See change: deck3d-cinematic-worlds. Captions come from `addPart` ONLY (measurable + billboarded + honours `labels.size`); builders must NOT also extrude a `buildTitle` per node. See change: deck3d-cinematic-worlds. |
| `backgrounds.ts` | Scene library `tokens`/`rings`/`swarm`/`particles` (+ `backgroundFor`); deterministic seeded RNG. |
- | `camera.ts` | `anchorFor(i, distance?)` rail anchor (40-unit spacing, per-slide `camera.distance` sets the camera z) + `CULL_RADIUS` (52). |
- | `scene.ts` | `createSceneRig`: renderer (ACES tone map), scene, camera, `EffectComposer` (bloom pass only when the quality profile enables it), RoomEnvironment PMREM, key/fill/rim lights, ONE global floor (Reflector mirror + radial-alpha veil), `applyLook` (mode-aware bloom/fog/lights/shadows), `render`/`resize`/`updateFloor`. |
+ | `camera.ts` | `anchorFor(i, distance?, spacing?, rail?, count?)` rail anchor + `cullRadius(spacing, rail)` + `DEFAULT_SPACING` (40) + `CULL_RADIUS` (52 = 40×1.3, back-compat). `rail`: `line` (x = i·spacing), `orbit` (ring, radius = spacing·count/2π, `rotY` = θ), `tunnel` (-Z, cull 0.6×spacing so the slide behind cannot float in frame), `helix` (orbit + 0.18·spacing rise), `grid` (cols = ⌈√count⌉, rows 0.8×spacing). Camera always sits at slide-local `(0.6, 0.6, distance)` turned by `rotY`, so framing is rail-invariant. See change: deck3d-cinematic-worlds. |
+ | `post.ts` | Post-processing stack (`createPostStack`). One slot per `post` corpus card, created lazily, enabled per slide, params → uniforms, CANONICAL order `POST_ORDER` (never `effects[]` order). Slots: `bloom` (baseline follows quality tier; card adds `intensity`), `selective-bloom` (`SelectiveBloomPass`: masked layered re-render + additive mix; `parts` = diagram/title/props/all via `userData.part` tags), `film` (`DeterministicFilmPass` pins `time` to the deck clock — three's accumulates wall time), `vignette`, `smaa`, `sao` (three `SAOPass`; id was `n8ao`, a lib never depended on), `depth-of-field` (`BokehPass`; `focus: 0` = camera→anchor auto focus), `god-rays` (own radial-shaft shader from the rim light projected each frame; three's is 4 passes), `chromatic-aberration` (RGBShift), `dot-screen` (LOCAL clamped copy — three's emits negative RGB in dark areas which OutputPass ACES turns WHITE), `sobel` (Luminosity+Sobel), `pixelate` (UV-quantise ShaderPass, NOT `RenderPixelatedPass` which replaces the render pass and drops backdrop isolation), `outline` (`OutlinePass` on `parts`). `ascii` is a DOM mode: hides the canvas, draws `<pre class="deck3d-ascii">` from the framebuffer each frame (gamma 1.5 so a dim floor reads as space). `probe()` backs `debug.post()`; `any()` false ⇒ rig draws directly (no composer copy). GPU frames are NOT bit-exact across runs under bloom (≤2 levels on a few pixels) — tests use a 4-level/0.01% tolerance, `deck.html` bytes stay exact. See change: deck3d-cinematic-worlds. |
+ | `scene.ts` | `createSceneRig`: renderer (ACES tone map, `localClippingEnabled`), scene, camera, post stack from `post.ts`; floor = ONE global group with `Reflector` mirror + optional `Water` (three `shaders_ocean`, created lazily on first `defaults.floor: "water"`, procedural `DataTexture` normals from `fx/util.makeNoise3` + fixed seed, `time` fed from the deck clock in `render(t)`, `waterColor` = palette bg→second, `sunColor` = accent; `mirrorFloor:false` hides either) + radial-alpha veil; `floorMode()` backs `debug.look().floor` (`setPost`/`postProbe`/`sunPosition`; `render(t)` feeds the deck clock to time-dependent passes; the old `bloom`/`composer` fields are gone), RoomEnvironment PMREM, key/fill/rim lights, ONE global floor (Reflector mirror + radial-alpha veil), `applyLook` (mode-aware bloom/fog/lights/shadows), `render`/`resize`/`updateFloor`. `applySurface` repaints background/veil on EVERY call (a first-run-only guard froze the deck's dominant colour after boot). `rimColor()` exposes the live rim light. `BACKDROP_LAYER` (1) + `markBackdrop(object)` + `BackdropRenderPass` + `renderLayered`: backdrop and content render as TWO passes with the depth buffer cleared between, so a background can never occlude slide text however deep its geometry (fixed `geo-fragments` plates slicing the card). Lights `layers.enable(BACKDROP_LAYER)` or standard materials go black there; the content pass suppresses `autoClear*` because a Color background force-clears on every render. Pass still reports as `RenderPass` in `effects().active`. See change: deck3d-cinematic-worlds. Floor/veil/mirror are marked backdrop too — on the content layer the floor painted over every backdrop pixel below its horizon (depth is cleared between passes), clipping backgrounds along a hard line. The content pass suppresses the clear via `renderer.autoClear = false` + `scene.background = null`, NOT the `autoClearColor/Depth/Stencil` sub-flags: `Reflector` clears its own target only when `autoClear === false`, so the sub-flag spelling left the mirror uncleared and reflections accumulated every past frame. See change: deck3d-cinematic-worlds. `applySurface` drives `mirror.camera.layers` from `defaults.reflectBackdrop` (default true) and `reflectsBackdrop()` backs `debug.look().reflectBackdrop`: `Reflector` reflects with its OWN bare `PerspectiveCamera` (`virtualCamera = this.camera`), so its mask is the content layer only and `BACKDROP_LAYER` backgrounds/local fx were absent from every reflection once the layer split landed (the lab reflected them because it had no layers). Set per `applyLook`, not at construction, so the configurator toggles it live. See change: deck3d-cinematic-worlds. `followTarget(target)` (was `updateFloor`) parks BOTH global-scenery rigs over the slide in frame: floor x/z AND `key.position`/`key.target`/`rimLight` (`KEY_OFFSET` 6,10,8 / `RIM_OFFSET` -6,3,-4). The lights were left at the world origin while the rail puts slide i at x=i*40, so the "shadow frustum is ±14 around its TARGET" meant every slide but the first cast NOTHING, and the rim spot (distance 40) never reached them. The lab moved the target only; the position travels too, or a far slide swings the key to a grazing angle and sits past `shadow.camera.far`. `shadowCamera()` backs `debug.shadows()`. `key.shadow.camera.updateProjectionMatrix()` after the ortho-bounds `Object.assign`: `LightShadow.updateMatrices` re-derives the frustum from the EXISTING projection matrix, so the ±14/±10 written at construction did nothing and three's default ±5 box clipped the slide. `renderLayered` sets `shadowMap.autoUpdate = false` and raises `needsUpdate` in the CONTENT pass only: three filters shadow casters by the VIEW camera's layers (`WebGLShadowMap.renderObject`), so the backdrop pass sees no caster and refreshing there wrote an EMPTY map over it — the floor, drawn in that pass, received nothing. `resetShadowPriming()` + a one-off content draw on the first frame fill the map cold. `MATTE_REFLECTOR_FRAGMENT` replaces `Reflector`'s one-tap `texture2DProj` mirror: `defaults.floorMatte` (0 mirror, 1 scattered) jitters the sample with the SHARED seeded noise (`noiseTexture()`, also the water normals — a deterministic build cannot call `Math.random` in a shader), spreads 5 taps widening with distance, and fades toward the floor colour. `reflectivity`+`floorColor` uniforms fade the reflection toward the palette bg (NOT toward black, or a light-mode floor goes dark). `floorMatte()`/`floorReflectivity()` read the UNIFORMS, not the config. `floorFade` (fadeColor=palette bg, 26→85 view units) blends BOTH floor surfaces into the background by DISTANCE, shared by the mirror shader (custom vertex passes `vFloorDist`) and the veil (`onBeforeCompile`, injected after `dithering_fragment` so shadows survive). Veil alpha ramp reaches opaque bg at r~170 of the 200-unit plane (was r~60) and `veil.material.fog = false` — a tight ramp plus fog 14-36 ended the reflection long before the horizon, leaving a flat-bg strip that read as a black bar under a bright backdrop (worst on slides with effects straddling the horizon). Fog alone used to hide the 400x400 plane's far edge; `Reflector` ignores fog and the veil is LIT, so `fog:false` met unlit background with lit veil-bg in a hard horizon seam (measured max row jump 35.7 → 2.5 with fog on; now <10 either way). See change: deck3d-cinematic-worlds. See change: deck3d-cinematic-worlds. | `sortAboveVeil(object)` + `VEIL_RENDER_ORDER` (=1): any node whose material has `depthWrite:false` is bumped to renderOrder 2 (never demoted). Blending effects write no depth, so the veil's opaque far band passed its depth test against the floor BEHIND them and overpainted them — a background stripe across the horizon (loud on geo-fragments plates, quiet on constellation points). Called from `index.ts` at all three fx attach sites so `local:` modules and future effects inherit it. See change: deck3d-cinematic-worlds.
| `rng.ts` | Deterministic LCG so no scene layout uses `Math.random`. |
+ | `hud.ts` | Configurator (design D4): `createHud(host)` builds the hidden `#deck3d-hud` panel — slide counter, Deck/This-slide scopes, seven collapsible `<details>` blocks (Look, Lighting & FX, Camera & labels, Layout, Motion, Quality, Effects) driven by the `BLOCKS` control-plane table; only the first opens by default and per-block open state persists in `HudState.open`. A block with no control for the active scope is omitted — `rail`/`spacing` are deck-only, `diagram.*`/`cardOffset.*` slide-only. Effects checklist, bounded autoplay (`parseAutoplay`: 0 = off, else integer 1–600), `●` markers from the embedded `overriddenKeys`, and Export → `overrides.json` in D1 grammar. State lives in memory + `localStorage["deck3d:"+derivedHash]`; `window.__DECK` is never mutated. Served (`window.__deck3dServe === true`) adds **Save overrides.json** (POST `/__overrides`) and **Apply to deck.json** (POST `/__apply`, merges then rebuilds); unserved falls back to the in-panel copyable payload — a sandboxed iframe without `allow-downloads` drops a blob download silently. See change: deck3d-cinematic-worlds. `paramRows` generates per-effect controls from the card's declared params (slider on min/max, checkbox on boolean, select on enum); `commitParam` applies live + stages for export as `effects[].params`. The Effects block both ADDS and removes: rows cover the effective list (composed ∪ staged) and `addEffectPicker` offers `host.catalogue` (corpus + embedded `local:` cards, grouped by kind) minus what the slide lists; `commitEffects` stages, applies and re-renders. See change: deck3d-cinematic-worlds. `restoreToScene()` replays `localStorage` state through `applyDeck` at boot — restoring controls alone left the panel disagreeing with the scene. See change: deck3d-cinematic-worlds. Lighting & FX block carries `reflectBackdrop` + `floorReflectivity` + `floorMatte`. New control kind `range`/`rangeC(path,min,max,step)` renders a real `<input type=range>` SLIDER and stages on `input` (not `change`) so the frame follows the thumb while dragging; `numC` stays a typed box with no bounds. See change: deck3d-cinematic-worlds. | Effects are SLOTS (`effectSlots`/`slotRow`): each slot = kind picker + prev/next stepper over that kind + the effect's params + remove, with `+ add slot`; replaced the flat checklist and its `addEffectPicker` over a 79-effect corpus. Deck-level effects render as read-only `[data-inherited]` slots at slide scope, since `applyOverrides` PREPENDS them to every slide. Control scope is schema-bound: `f45-panel-scope-matches-schema` fails if a block offers a key the IR rejects for that scope. See change: deck3d-cinematic-worlds.
+ | `local-fx.ts` | Local-effect instantiation (design D1): `SHADOWED` global list, `compile` via `new Function` with those names bound to `undefined` and a frozen `Math` whose `random` is `ctx.rng`. NO `"use strict"` — `eval`/`Function` are illegal strict-mode parameter names, so strict mode made every local module fail to compile. `createLocalEffect` wraps create/tick/dispose; a throw disables that effect only and records `{slide,effectId,phase}`. `localFxRegistry`/`localCards` read `window.__DECK_LOCAL_FX`. See change: deck3d-cinematic-worlds. |