From ac53490d5827744689fb2561ddeec82a74876887 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 21 Jun 2026 19:15:12 -0500 Subject: [PATCH 01/28] =?UTF-8?q?feat(dashboard):=20launch=20quick-wins=20?= =?UTF-8?q?=E2=80=94=20view=20transitions,=20OKLCH/P3=20palette,=20reduced?= =?UTF-8?q?-motion-ready,=20responsive=20graph=20controls,=20ws=20reconnec?= =?UTF-8?q?t=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Native View Transitions API via onNavigate (feature-detected, reduced-motion safe) - OKLCH + display-p3 accent palette with hex fallback (@supports progressive enhancement) - WebSocket gains 'reconnecting' state so stale errors clear on reconnect - Graph control bar wraps + safe-area insets for <640px / notched phones Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/app.css | 67 +++++++++++++++++++ apps/dashboard/src/lib/stores/websocket.ts | 8 ++- .../src/routes/(app)/graph/+page.svelte | 30 +++++---- apps/dashboard/src/routes/+layout.svelte | 24 ++++--- 4 files changed, 105 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index 418467a..d7c0f7c 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -43,6 +43,43 @@ html { font-family: var(--font-mono); } +/* ═══════════════════════════════════════════ + OKLCH / DISPLAY-P3 ACCENT PALETTE (PROGRESSIVE ENHANCEMENT) + ═══════════════════════════════════════════ + The @theme block above keeps the original sRGB hex values, which + Tailwind reads at build time and which serve as the fallback for + sRGB monitors and browsers without OKLCH support. + + When the browser understands oklch(), we redefine the SAME vivid + accents + node-type colors using their OKLCH equivalents. These are + faithful conversions of the hex values (same hue/chroma identity); + on a wide-gamut display-p3 monitor they render more saturated while + reading as the same color. The void/abyss/surface neutrals are left + untouched — only the vivid accents benefit from the wider gamut. */ +@supports (color: oklch(0 0 0)) { + :root { + /* Accent colors */ + --color-synapse: oklch(0.585 0.222 277); + --color-synapse-glow: oklch(0.685 0.169 277); + --color-dream: oklch(0.627 0.265 304); + --color-dream-glow: oklch(0.714 0.203 305); + --color-memory: oklch(0.623 0.214 259); + --color-recall: oklch(0.696 0.17 162); + --color-decay: oklch(0.637 0.237 25); + --color-warning: oklch(0.769 0.188 70); + + /* Node type colors */ + --color-node-fact: oklch(0.623 0.214 259); + --color-node-concept: oklch(0.606 0.25 292); + --color-node-event: oklch(0.769 0.188 70); + --color-node-person: oklch(0.696 0.17 162); + --color-node-place: oklch(0.715 0.143 215); + --color-node-note: oklch(0.551 0.027 264); + --color-node-pattern: oklch(0.656 0.241 354); + --color-node-decision: oklch(0.637 0.237 25); + } +} + body { margin: 0; min-height: 100vh; @@ -234,3 +271,33 @@ body { .retention-low { color: var(--color-warning); } .retention-good { color: var(--color-recall); } .retention-strong { color: var(--color-synapse); } + +/* ═══════════════════════════════════════════ + VIEW TRANSITIONS (CROSSFADE) + ═══════════════════════════════════════════ + Native View Transitions API crossfade between routes. Pairs with the + onNavigate hook in +layout.svelte that calls document.startViewTransition. + Reduced-motion users get an instant cut (the @media guard disables the + animation entirely, so the default browser cross-fade does not run). */ +@media not (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 180ms; + animation-timing-function: ease; + } + ::view-transition-old(root) { + animation-name: vt-fade-out; + } + ::view-transition-new(root) { + animation-name: vt-fade-in; + } + + @keyframes vt-fade-out { + from { opacity: 1; } + to { opacity: 0; } + } + @keyframes vt-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } +} diff --git a/apps/dashboard/src/lib/stores/websocket.ts b/apps/dashboard/src/lib/stores/websocket.ts index 8554581..fda02f9 100644 --- a/apps/dashboard/src/lib/stores/websocket.ts +++ b/apps/dashboard/src/lib/stores/websocket.ts @@ -6,11 +6,13 @@ const MAX_EVENTS = 200; function createWebSocketStore() { const { subscribe, set, update } = writable<{ connected: boolean; + reconnecting: boolean; events: VestigeEvent[]; lastHeartbeat: VestigeEvent | null; error: string | null; }>({ connected: false, + reconnecting: false, events: [], lastHeartbeat: null, error: null @@ -32,7 +34,7 @@ function createWebSocketStore() { ws.onopen = () => { reconnectAttempts = 0; - update(s => ({ ...s, connected: true, error: null })); + update(s => ({ ...s, connected: true, reconnecting: false, error: null })); }; ws.onmessage = (event) => { @@ -65,6 +67,7 @@ function createWebSocketStore() { function scheduleReconnect(url: string) { if (reconnectTimer) clearTimeout(reconnectTimer); + update(s => ({ ...s, reconnecting: true })); const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000); reconnectAttempts++; reconnectTimer = setTimeout(() => connect(url), delay); @@ -74,7 +77,7 @@ function createWebSocketStore() { if (reconnectTimer) clearTimeout(reconnectTimer); ws?.close(); ws = null; - set({ connected: false, events: [], lastHeartbeat: null, error: null }); + set({ connected: false, reconnecting: false, events: [], lastHeartbeat: null, error: null }); } function clearEvents() { @@ -108,6 +111,7 @@ export const websocket = createWebSocketStore(); // Derived stores for specific event types export const isConnected = derived(websocket, $ws => $ws.connected); +export const isReconnecting = derived(websocket, $ws => $ws.reconnecting); export const eventFeed = derived(websocket, $ws => $ws.events); export const heartbeat = derived(websocket, $ws => $ws.lastHeartbeat); export const memoryCount = derived(websocket, $ws => diff --git a/apps/dashboard/src/routes/(app)/graph/+page.svelte b/apps/dashboard/src/routes/(app)/graph/+page.svelte index 1df2683..a06dbe7 100644 --- a/apps/dashboard/src/routes/(app)/graph/+page.svelte +++ b/apps/dashboard/src/routes/(app)/graph/+page.svelte @@ -262,34 +262,38 @@ disown {/if} -
+
-
+
e.key === 'Enter' && searchGraph()} - class="flex-1 px-3 py-2 glass rounded-xl text-text text-sm + class="flex-1 min-w-0 px-3 py-2 glass rounded-xl text-text text-sm placeholder:text-muted focus:outline-none focus:!border-synapse/40 transition" />
-
+
-
+
+
+
+ + +
+ {#if chip}
{chip}
{/if} +

{caption}

+ +
+ {#if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if} + {#if stage === 'done'}{/if} +
+
+
+{/if} + + diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index 2243898..3ede9f6 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -37,8 +37,13 @@ export class CinemaSandbox { private container: HTMLElement; private deps!: SandboxDeps; private renderer!: SandboxDeps['WebGPURenderer']['prototype']; - private scene = new THREE.Scene(); - private camera: THREE.PerspectiveCamera; + // Scene/camera are created in boot() from the three/webgpu module so every + // object handed to the WebGPU renderer comes from the SAME Three.js instance + // (avoids the "multiple instances of Three.js" incompatibility — the base + // three import is used only for the shared Vector3 math type the director + // mutates, which is identical across instances). + private scene!: THREE.Scene; + private camera!: THREE.PerspectiveCamera; private storm!: SemanticComputeStorm; private post: { renderAsync: () => Promise } | null = null; private booted = false; @@ -48,14 +53,6 @@ export class CinemaSandbox { constructor(container: HTMLElement) { this.container = container; - this.camera = new THREE.PerspectiveCamera( - 60, - container.clientWidth / Math.max(1, container.clientHeight), - 0.1, - 2000 - ); - this.camera.position.set(0, 18, 60); - this.scene.background = new THREE.Color(0x02020a); } get cameraRef(): THREE.PerspectiveCamera { @@ -74,6 +71,9 @@ export class CinemaSandbox { const webgpu = (await import('three/webgpu')) as unknown as { WebGPURenderer: SandboxDeps['WebGPURenderer']; PostProcessing: SandboxDeps['PostProcessing']; + Scene: new () => THREE.Scene; + PerspectiveCamera: new (fov: number, aspect: number, near: number, far: number) => THREE.PerspectiveCamera; + Color: new (hex: number) => THREE.Color; }; const tsl = (await import('three/tsl')) as typeof import('three/tsl'); // bloom() lives in the TSL display helpers; import the node module. @@ -90,9 +90,18 @@ export class CinemaSandbox { bloomMod, }; + // Build scene + camera from the SAME (webgpu) module instance the + // renderer + storm use, so all objects are instance-compatible. + const w = Math.max(1, this.container.clientWidth); + const h = Math.max(1, this.container.clientHeight); + this.scene = new webgpu.Scene(); + this.scene.background = new webgpu.Color(0x02020a); + this.camera = new webgpu.PerspectiveCamera(60, w / h, 0.1, 2000); + this.camera.position.set(0, 18, 60); + const renderer = new this.deps.WebGPURenderer({ antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(this.container.clientWidth, this.container.clientHeight); + renderer.setSize(w, h); // CRITICAL FOOTGUN: WebGPU init is async. Must await before first render // or the canvas silently draws nothing. await renderer.init(); diff --git a/apps/dashboard/src/routes/(app)/graph/+page.svelte b/apps/dashboard/src/routes/(app)/graph/+page.svelte index a06dbe7..57c8d8f 100644 --- a/apps/dashboard/src/routes/(app)/graph/+page.svelte +++ b/apps/dashboard/src/routes/(app)/graph/+page.svelte @@ -5,6 +5,7 @@ import RetentionCurve from '$components/RetentionCurve.svelte'; import TimeSlider from '$components/TimeSlider.svelte'; import MemoryStateLegend from '$components/MemoryStateLegend.svelte'; + import MemoryCinema from '$components/MemoryCinema.svelte'; import { api } from '$stores/api'; import { eventFeed } from '$stores/websocket'; import { graphState } from '$stores/graph-state.svelte'; @@ -361,6 +362,15 @@ disown {isDreaming ? '◈ Dreaming...' : '◈ Dream'} + + {#if displayNodes.length > 0} + + {/if} + {#if open} - diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts index 75ba56f..7a3d3c3 100644 --- a/apps/dashboard/src/lib/graph/cinema/director.ts +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -141,7 +141,7 @@ export class CinemaDirector { update(deltaSeconds: number): void { if (this.phase === 'idle' || this.phase === 'done') return; // Clamp dt so a tab-switch stall doesn't teleport the camera. - const dt = Math.min(deltaSeconds, 0.05); + const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); this.phaseElapsed += dt; if (this.phase === 'flying') { @@ -174,7 +174,8 @@ export class CinemaDirector { } // Overall progress across the whole tour (beat + intra-beat fraction). - const per = 1 / this.path.beats.length; + // Guard against an empty path (per = 0) so progress can never be NaN. + const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0; const intra = this.phase === 'flying' ? Math.min(1, this.phaseElapsed / this.opts.flightSeconds) * 0.5 diff --git a/apps/dashboard/src/lib/graph/cinema/narrator.ts b/apps/dashboard/src/lib/graph/cinema/narrator.ts index 9325cb7..8210cbe 100644 --- a/apps/dashboard/src/lib/graph/cinema/narrator.ts +++ b/apps/dashboard/src/lib/graph/cinema/narrator.ts @@ -27,13 +27,15 @@ export interface CinemaNarration { beats: BeatNarration[]; } -const KIND_CHIP: Record = { +// `satisfies` makes the compiler error if a new CinemaBeat['kind'] is added +// without a chip here — closes the silent "undefined chip → blank UI" gap. +const KIND_CHIP = { origin: 'Origin', connection: 'Connection', contradiction: 'Tension', recent: 'Now', bridge: 'Jump', -}; +} satisfies Record; function snippet(content: string, max = 90): string { const s = (content ?? '').replace(/\s+/g, ' ').trim(); @@ -100,26 +102,46 @@ export async function resolveNarration( const fallback = localCaptions(path); if (!fetchBackend) return fallback; + let timer: ReturnType | undefined; try { - const withTimeout = Promise.race([ + const backend = await Promise.race([ fetchBackend(), - new Promise((resolve) => setTimeout(() => resolve(null), 6000)), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), 6000); + }), ]); - const backend = await withTimeout; - if (!Array.isArray(backend) || backend.length === 0) return fallback; - // Align backend beats to the real path by nodeId; fill any gaps from - // local captions so every beat always has text (never a blank shot). - const byNode = new Map(backend.map((b) => [b.nodeId, b])); + // Keep only well-formed backend beats (guards against null/empty/garbage + // entries that would otherwise produce blank captions mid-tour). + const valid = Array.isArray(backend) + ? backend.filter( + (b): b is BeatNarration => + !!b && typeof b.nodeId === 'string' && typeof b.text === 'string' && b.text.trim().length > 0 + ) + : []; + if (valid.length === 0) return fallback; + + // Align backend beats to the real path by nodeId; fill any gap from the + // bounds-safe local caption so every beat always has text (never blank). + const byNode = new Map(valid.map((b) => [b.nodeId, b])); const beats: BeatNarration[] = path.beats.map((beat, i) => { const hit = byNode.get(beat.nodeId); - if (hit && typeof hit.text === 'string' && hit.text.trim()) { - return { nodeId: beat.nodeId, text: hit.text, chip: hit.chip || KIND_CHIP[beat.kind] }; + if (hit) { + const chip = typeof hit.chip === 'string' && hit.chip.trim() ? hit.chip : KIND_CHIP[beat.kind]; + return { nodeId: beat.nodeId, text: hit.text, chip }; } - return fallback.beats[i]; + return ( + fallback.beats[i] ?? { + nodeId: beat.nodeId, + text: beat.node.label || '(unlabeled memory)', + chip: KIND_CHIP[beat.kind], + } + ); }); return { source: 'backend-llm', beats }; } catch { return fallback; + } finally { + if (timer) clearTimeout(timer); } } diff --git a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts index 76f27d5..0c4cd20 100644 --- a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts +++ b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts @@ -30,7 +30,12 @@ export interface CinemaBeat { export interface CinemaPath { beats: CinemaBeat[]; + /** The node the requested centerId resolved to (which the tour actually + * starts from). May differ from the requested centerId when it was missing, + * in which case `pivoted` is true — callers can surface this if they care. */ centerId: string; + /** True when the requested centerId did not exist and we picked a start node. */ + pivoted: boolean; /** Edges that should visibly "flow" during the tour, in beat order. */ flowEdges: GraphEdge[]; } @@ -77,14 +82,16 @@ export function planCinemaPath( maxBeats = 7 ): CinemaPath { const byId = new Map(nodes.map((n) => [n.id, n])); - const empty: CinemaPath = { beats: [], centerId, flowEdges: [] }; + const empty: CinemaPath = { beats: [], centerId, pivoted: false, flowEdges: [] }; if (nodes.length === 0) return empty; // Resolve a real starting node: prefer centerId, else the explicit center - // flag, else the most-connected node, else the first node. + // flag, else the most-connected node, else the first node. Track whether we + // had to pivot off the requested centerId so callers can surface it. const adj = buildAdjacency(edges); - let startId = byId.has(centerId) ? centerId : ''; - if (!startId) startId = nodes.find((n) => (n as { isCenter?: boolean }).isCenter)?.id ?? ''; + const requestedExists = byId.has(centerId); + let startId = requestedExists ? centerId : ''; + if (!startId) startId = nodes.find((n) => n.isCenter)?.id ?? ''; if (!startId) { startId = nodes .map((n) => ({ id: n.id, deg: adj[n.id]?.length ?? 0 })) @@ -92,6 +99,7 @@ export function planCinemaPath( } const start = byId.get(startId); if (!start) return empty; + const pivoted = !requestedExists; const visited = new Set([startId]); const beats: CinemaBeat[] = [ @@ -159,5 +167,5 @@ export function planCinemaPath( } } - return { beats, centerId: startId, flowEdges }; + return { beats, centerId: startId, pivoted, flowEdges }; } diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index 3ede9f6..5c37b1a 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -90,6 +90,12 @@ export class CinemaSandbox { bloomMod, }; + // Fail loud if the dynamic import didn't yield the expected constructors, + // instead of a cryptic "undefined is not a constructor" later. + if (!webgpu.WebGPURenderer || !webgpu.Scene || !webgpu.PerspectiveCamera || !webgpu.Color) { + throw new Error('[cinema] three/webgpu is missing expected exports'); + } + // Build scene + camera from the SAME (webgpu) module instance the // renderer + storm use, so all objects are instance-compatible. const w = Math.max(1, this.container.clientWidth); @@ -126,6 +132,9 @@ export class CinemaSandbox { emissive: unknown; }; const scenePass = pass(this.scene, this.camera); + if (typeof scenePass?.setMRT !== 'function' || typeof scenePass?.getTextureNode !== 'function') { + throw new Error('three/tsl pass() API mismatch — setMRT/getTextureNode missing'); + } scenePass.setMRT(mrt({ output, emissive })); const outputTex = scenePass.getTextureNode('output'); const emissiveTex = scenePass.getTextureNode('emissive'); @@ -150,7 +159,9 @@ export class CinemaSandbox { this.storm.transitionTo(role, worldPos); } - /** Render one frame. Camera is driven externally (director mutates position/target). */ + /** Render one frame. Camera is driven externally (director mutates position/target). + * A single frame's failure must not crash the tour — it's caught and surfaced + * via a thrown error the caller already handles (drops to camera-only). */ async render(deltaSeconds: number): Promise { if (!this.booted) return; this.camera.lookAt(this.target); @@ -161,9 +172,9 @@ export class CinemaSandbox { resize(): void { if (!this.booted) return; - const w = this.container.clientWidth; - const h = this.container.clientHeight; - this.camera.aspect = w / Math.max(1, h); + const w = Math.max(1, this.container.clientWidth); + const h = Math.max(1, this.container.clientHeight); + this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); } diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index e7e6ca9..9ac9173 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -36,6 +36,7 @@ import { float, sin, cos, + positionLocal, } from 'three/tsl'; export type SemanticRole = 'anchor' | 'connection' | 'contradiction'; @@ -57,28 +58,38 @@ export interface StormOptions { * update(dt) each frame, and transitionTo(role, worldPos) on each narrative * beat. dispose() releases all GPU resources. */ +/** The TSL compute node Fn(...)().compute(count) produces. three@0.172 does not + * export a public type for it; it is opaque and only handed to computeAsync(). */ +type ComputeDispatch = ReturnType>['compute']>; + export class SemanticComputeStorm { readonly count: number; private scene: THREE.Scene; // WebGPURenderer — runtime-only type (dynamic import); see file header. - private renderer: { computeAsync: (node: unknown) => Promise }; + private renderer: { computeAsync: (node: ComputeDispatch) => Promise }; - private bufferPos: StorageBufferAttribute; - private bufferVel: StorageBufferAttribute; - private bufferPhase: StorageBufferAttribute; + private bufferPos: StorageBufferAttribute | null; + private bufferVel: StorageBufferAttribute | null; + private bufferPhase: StorageBufferAttribute | null; - private computeNode: unknown; - private mesh!: THREE.Object3D; - private material!: THREE.Material; + // Definite-assigned in buildCompute() (called from the constructor). + private computeNode!: ComputeDispatch; + private mesh: THREE.InstancedMesh | null = null; + private material: THREE.Material | null = null; - // Uniforms driven from the camera/beat loop. + // Serialize GPU compute dispatches: never queue a new compute pass before the + // previous one resolves, or the WebGPU dispatch queue backs up and stalls. + private computeInFlight: Promise | null = null; + + // Uniforms driven from the camera/beat loop. uIgnition starts non-zero so + // the storm is visible on the very first frame (before any beat fires). private uTarget = uniform(new THREE.Vector3(0, 0, 0)); private uTime = uniform(0); - private uIgnition = uniform(0); + private uIgnition = uniform(0.6); private uMode = uniform(0); constructor( - renderer: { computeAsync: (node: unknown) => Promise }, + renderer: { computeAsync: (node: ComputeDispatch) => Promise }, scene: THREE.Scene, opts: StormOptions = {} ) { @@ -96,18 +107,25 @@ export class SemanticComputeStorm { positions[i * 3 + 2] = (Math.random() - 0.5) * spawn; phases[i] = Math.random() * Math.PI * 2; } - this.bufferPos = new StorageBufferAttribute(positions, 3); - this.bufferVel = new StorageBufferAttribute(velocities, 3); - this.bufferPhase = new StorageBufferAttribute(phases, 1); + const bufferPos = new StorageBufferAttribute(positions, 3); + const bufferVel = new StorageBufferAttribute(velocities, 3); + const bufferPhase = new StorageBufferAttribute(phases, 1); + this.bufferPos = bufferPos; + this.bufferVel = bufferVel; + this.bufferPhase = bufferPhase; - this.buildCompute(); - this.buildRender(); + this.buildCompute(bufferPos, bufferVel, bufferPhase); + this.buildRender(bufferPos); } - private buildCompute(): void { - const posStore = storage(this.bufferPos, 'vec3', this.count); - const velStore = storage(this.bufferVel, 'vec3', this.count); - const phaseStore = storage(this.bufferPhase, 'float', this.count); + private buildCompute( + bufferPos: StorageBufferAttribute, + bufferVel: StorageBufferAttribute, + bufferPhase: StorageBufferAttribute + ): void { + const posStore = storage(bufferPos, 'vec3', this.count); + const velStore = storage(bufferVel, 'vec3', this.count); + const phaseStore = storage(bufferPhase, 'float', this.count); this.computeNode = Fn(() => { const pos = posStore.element(instanceIndex); @@ -151,7 +169,7 @@ export class SemanticComputeStorm { })().compute(this.count); } - private buildRender(): void { + private buildRender(bufferPos: StorageBufferAttribute): void { // SpriteNodeMaterial: emissive routed to bloom; additive against the void. const mat = new SpriteNodeMaterial({ transparent: true, @@ -159,7 +177,14 @@ export class SemanticComputeStorm { depthWrite: false, }) as SpriteNodeMaterial & { positionNode: unknown; colorNode: unknown }; - mat.positionNode = storage(this.bufferPos, 'vec3', this.count).element(instanceIndex); + // CRITICAL: particle world position = per-instance GPU compute output + // (storage buffer, indexed by instanceIndex) PLUS the sprite's local quad + // vertex (positionLocal) so each billboard keeps its size while being + // translated to its computed position. Assigning the bare storage element + // to positionNode (without positionLocal) collapses every quad to a point + // at its instance origin — the bug the audit caught. + const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex); + mat.positionNode = instancePos.add(positionLocal); mat.colorNode = Fn(() => { const anchor = vec3(0.0, 1.0, 0.85); // luminescent cyan @@ -171,7 +196,9 @@ export class SemanticComputeStorm { select(this.uMode.equal(1), link, contradiction) ); // Brighten on ignition so the beat blazes through the bloom pass. - return base.mul(this.uIgnition.mul(3.0).add(0.4)); + // The +0.55 floor keeps particles visibly glowing between beats so the + // storm never fades to black once ignition decays. + return base.mul(this.uIgnition.mul(3.0).add(0.55)); })(); // One instanced sprite per particle; positions come from the GPU storage @@ -185,13 +212,21 @@ export class SemanticComputeStorm { this.scene.add(this.mesh); } - /** Advance the GPU physics one frame. */ + /** Advance the GPU physics one frame. Compute dispatches are serialized so + * a slow GPU never lets passes pile up and stall the queue. */ async update(deltaSeconds: number): Promise { - this.uTime.value += deltaSeconds; - if (this.uIgnition.value > 0) { - this.uIgnition.value = Math.max(0, this.uIgnition.value - deltaSeconds * 2.0); - } - await this.renderer.computeAsync(this.computeNode); + const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); + this.uTime.value += dt; + // Ignition decays toward 0 between beats (the colorNode floor keeps the + // storm glowing); spikes back up on transitionTo(). + this.uIgnition.value = Math.max(0, this.uIgnition.value - dt * 2.0); + + // Wait for any in-flight compute to finish before queuing the next. + if (this.computeInFlight) await this.computeInFlight; + this.computeInFlight = this.renderer.computeAsync(this.computeNode).finally(() => { + this.computeInFlight = null; + }); + await this.computeInFlight; } /** Fired on each narrative beat: retarget the storm + spike ignition. */ @@ -202,8 +237,20 @@ export class SemanticComputeStorm { } dispose(): void { - this.scene.remove(this.mesh); - (this.mesh as THREE.InstancedMesh).geometry?.dispose(); + if (this.mesh) { + this.scene.remove(this.mesh); + this.mesh.geometry?.dispose(); + this.mesh.dispose?.(); + this.mesh = null; + } this.material?.dispose(); + this.material = null; + // StorageBufferAttribute extends BufferAttribute, which has no dispose(): + // its GPU buffer is released by the renderer when the owning geometry is + // disposed (done above). Drop our references so the ~2.1MB of backing + // Float32Arrays can be garbage-collected. + this.bufferPos = null; + this.bufferVel = null; + this.bufferPhase = null; } } From dbf85291aff7f39e9489cb2bbbd8326699bdd0dc Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 00:14:29 -0500 Subject: [PATCH 06/28] fix(cinema): contain the particle storm on-screen (soft sphere + velocity clamp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Particles (esp. the unbounded Rössler chaos mode) could fly off-screen. Add a camera-frame-sized spherical containment field: spring pull-back past the radius, hard velocity clamp, and a snap-to-shell safety net so no particle can escape. The sandbox sizes the radius from camera distance + vfov each frame so the storm reframes as the camera flies. Verified: check + build green. --- .../dashboard/src/lib/graph/cinema/sandbox.ts | 10 +++++ apps/dashboard/src/lib/graph/cinema/storm.ts | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index 5c37b1a..e3ab3f7 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -165,6 +165,16 @@ export class CinemaSandbox { async render(deltaSeconds: number): Promise { if (!this.booted) return; this.camera.lookAt(this.target); + + // Keep the storm inside the frame: derive the largest world radius that + // fully fits the camera's vertical FOV at the current distance to target, + // minus a margin so the glow halo stays on-screen too. The storm clamps + // itself to this each frame, so it reframes as the camera flies. + const dist = this.camera.position.distanceTo(this.target); + const vfov = (this.camera.fov * Math.PI) / 180; + const fitRadius = Math.tan(vfov / 2) * dist * 0.62; // 0.62 = on-screen margin + this.storm.setContainRadius(fitRadius); + await this.storm.update(deltaSeconds); if (this.post) await this.post.renderAsync(); else await this.renderer.renderAsync(this.scene, this.camera); diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index 9ac9173..bab0807 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -36,6 +36,10 @@ import { float, sin, cos, + length, + clamp, + min, + mix, positionLocal, } from 'three/tsl'; @@ -87,6 +91,10 @@ export class SemanticComputeStorm { private uTime = uniform(0); private uIgnition = uniform(0.6); private uMode = uniform(0); + // World-space radius the storm is contained within. Particles past this get + // a spring force back so the storm NEVER flies off-screen. Sized to the + // camera framing by the sandbox via setContainRadius(). + private uContainRadius = uniform(48); constructor( renderer: { computeAsync: (node: ComputeDispatch) => Promise }, @@ -164,8 +172,35 @@ export class SemanticComputeStorm { vel.addAssign(active); // Ignition shockwave yanks particles toward the new node on each beat. vel.addAssign(toTarget.normalize().mul(this.uIgnition.mul(0.02))); + + // CONTAINMENT: soft spherical boundary around the focused target so the + // storm NEVER escapes the camera frame. Past uContainRadius a spring + // force pulls each particle back toward the target; the force ramps in + // smoothly (smoothstep-like) so the boundary reads as a glowing + // membrane, not a hard wall. The chaos attractor (Rössler) is + // unbounded by nature — this is what keeps mode 2 on-screen. + const distFromTarget = length(toTarget.negate()); // |pos - target| + const overflow = distFromTarget.sub(this.uContainRadius).max(0); + const pullBack = clamp(overflow.mul(0.012), 0, 0.6); + vel.addAssign(toTarget.normalize().mul(pullBack)); + + // Hard velocity clamp so no single step can shoot a particle across + // the frame even at peak ignition / chaos divergence. + const speed = length(vel); + const maxSpeed = float(1.2); + vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001)))); + pos.addAssign(vel); vel.mulAssign(0.95); + + // Final safety net: if a particle still ends up beyond 1.35x the + // radius (extreme edge case), snap it onto the boundary shell so it + // can never be lost off-screen. + const finalToTarget = vec3(this.uTarget).sub(pos); + const finalDist = length(finalToTarget.negate()); + const hardR = this.uContainRadius.mul(1.35); + const snapped = vec3(this.uTarget).sub(finalToTarget.normalize().mul(hardR)); + pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0)))); })().compute(this.count); } @@ -236,6 +271,12 @@ export class SemanticComputeStorm { this.uIgnition.value = 8.0; } + /** Size the containment sphere (world units) so the storm always stays in + * frame. The sandbox derives this from the camera distance + fov. */ + setContainRadius(radius: number): void { + this.uContainRadius.value = Math.max(8, radius); + } + dispose(): void { if (this.mesh) { this.scene.remove(this.mesh); From a915d0fc5928f902dac5cd0b55e662bf209aca2c Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 00:51:20 -0500 Subject: [PATCH 07/28] =?UTF-8?q?feat(auteur):=20Phase=201=20=E2=80=94=20g?= =?UTF-8?q?raph=20signals=20+=20director=20contract=20(pure,=20headless)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spine of 'The Auteur': the LLM/rule-table becomes a film director. - topology.ts: computeSignals — Brandes betweenness, union-find clusters, recency, retention, suppression, edge surprise (Jaccard x distance). Reuses pathfinder internals (now exported). Betweenness capped for huge graphs. - auteur.ts: typed Shot/DirectorPlan/ResolvedShot contract; resolveShots carry-forward resolver (every axis back-filled prev->SHOT_DEFAULTS=today's camera constants, so a sparse/garbage plan ALWAYS yields a coherent film); planShotsDeterministic (Tier-2 pure auteur via graph-metric->shot-grammar rule table); directorSystemPrompt (same table → LLM prompt). - pathfinder: export buildAdjacency/recencyOf/isContradictionEdge/Adjacency; add 'surprise' beat kind. narrator KIND_CHIP gains 'surprise' (satisfies). - 11 new tests (carry-forward, garbage backfill, keystone betweenness, contradiction direction, determinism). 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/graph/cinema/__tests__/auteur.test.ts | 204 ++++++++++++++++ apps/dashboard/src/lib/graph/cinema/auteur.ts | 223 +++++++++++++++++ .../src/lib/graph/cinema/narrator.ts | 1 + .../src/lib/graph/cinema/pathfinder.ts | 10 +- .../src/lib/graph/cinema/topology.ts | 225 ++++++++++++++++++ 5 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts create mode 100644 apps/dashboard/src/lib/graph/cinema/auteur.ts create mode 100644 apps/dashboard/src/lib/graph/cinema/topology.ts diff --git a/apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts b/apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts new file mode 100644 index 0000000..d8fa1aa --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { planShotsDeterministic, resolveShots, SHOT_DEFAULTS, type DirectorPlan } from '../auteur'; +import { planCinemaPath } from '../pathfinder'; +import { computeSignals } from '../topology'; +import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers'; + +describe('auteur — carry-forward shot resolution', () => { + beforeEach(() => resetNodeCounter()); + + function smallPath() { + const a = makeNode({ id: 'a' }); + const b = makeNode({ id: 'b' }); + const c = makeNode({ id: 'c' }); + const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })]; + return { path: planCinemaPath([a, b, c], edges, 'a'), nodes: [a, b, c], edges }; + } + + it('fills EVERY axis from a one-field shot, defaulting to today constants', () => { + const { path } = smallPath(); + const plan: DirectorPlan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + shots: [{ nodeId: 'a', move: 'orbit', why: 'test' }], + }; + const resolved = resolveShots(plan, path); + expect(resolved).toHaveLength(path.beats.length); + // The specified field is honored… + expect(resolved[0].move).toBe('orbit'); + // …and every other axis is a real default, never undefined. + expect(resolved[0].standoff).toBe(SHOT_DEFAULTS.standoff); + expect(resolved[0].flightSeconds).toBe(SHOT_DEFAULTS.flightSeconds); + expect(resolved[0].angle).toBe('eye'); + for (const s of resolved) { + for (const k of Object.keys(SHOT_DEFAULTS) as (keyof typeof SHOT_DEFAULTS)[]) { + expect(s[k]).toBeDefined(); + } + expect(typeof s.why).toBe('string'); + expect(s.why.length).toBeGreaterThan(0); + } + }); + + it('carries non-cut axes forward to subsequent beats', () => { + const { path } = smallPath(); + const plan: DirectorPlan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + // Only the FIRST beat sets standoff; later beats should inherit it. + shots: [{ nodeId: path.beats[0].nodeId, standoff: 41, why: 'set' }], + }; + const resolved = resolveShots(plan, path); + expect(resolved[0].standoff).toBe(41); + expect(resolved[resolved.length - 1].standoff).toBe(41); // carried forward + }); + + it('cut never carries forward — defaults to fly each beat', () => { + const { path } = smallPath(); + const plan: DirectorPlan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + shots: [{ nodeId: path.beats[0].nodeId, cut: 'hard_cut', why: 'cut' }], + }; + const resolved = resolveShots(plan, path); + expect(resolved[0].cut).toBe('hard_cut'); + if (resolved.length > 1) expect(resolved[1].cut).toBe('fly'); + }); + + it('back-fills garbage / out-of-range LLM fields from defaults', () => { + const { path } = smallPath(); + const plan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + shots: [ + { + nodeId: path.beats[0].nodeId, + move: 'teleport', // invalid enum + standoff: 9999, // out of range + dwellSeconds: -5, // out of range + why: '', + }, + ], + } as unknown as DirectorPlan; + const resolved = resolveShots(plan, path); + expect(resolved[0].move).toBe(SHOT_DEFAULTS.move); // invalid → default + expect(resolved[0].standoff).toBeLessThanOrEqual(90); // clamped + expect(resolved[0].dwellSeconds).toBeGreaterThanOrEqual(0.6); // clamped + expect(resolved[0].why.length).toBeGreaterThan(0); // empty why → fallback + }); + + it('a null plan still yields one default shot per beat', () => { + const { path } = smallPath(); + const resolved = resolveShots(null, path); + expect(resolved).toHaveLength(path.beats.length); + expect(resolved[0].move).toBe(SHOT_DEFAULTS.move); + }); +}); + +describe('auteur — deterministic director', () => { + beforeEach(() => resetNodeCounter()); + + it('produces a valid plan: one grounded shot per beat, every why non-empty, every nodeId real', () => { + const nodes = Array.from({ length: 8 }, (_, i) => makeNode({ id: `n${i}` })); + const edges = [ + makeEdge('n0', 'n1', { weight: 0.9 }), + makeEdge('n1', 'n2', { weight: 0.2, type: 'contradiction' }), + makeEdge('n0', 'n3', { weight: 0.5 }), + makeEdge('n3', 'n4', { weight: 0.7 }), + ]; + const path = planCinemaPath(nodes, edges, 'n0'); + const signals = computeSignals(nodes, edges); + const plan = planShotsDeterministic(path, signals); + const realIds = new Set(nodes.map((n) => n.id)); + expect(plan.shots).toHaveLength(path.beats.length); + for (const s of plan.shots) { + expect(realIds.has(s.nodeId)).toBe(true); + expect(s.why && s.why.length).toBeGreaterThan(0); + } + expect(plan.source).toBe('deterministic'); + expect(plan.logline.length).toBeGreaterThan(0); + }); + + it('directs a contradiction beat as a Dutch hard-cut crimson collision', () => { + const a = makeNode({ id: 'a' }); + const normal = makeNode({ id: 'normal' }); + const conflict = makeNode({ id: 'conflict' }); + const edges = [ + makeEdge('a', 'normal', { weight: 0.95 }), + makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }), + ]; + const path = planCinemaPath([a, normal, conflict], edges, 'a'); + const signals = computeSignals([a, normal, conflict], edges); + const plan = planShotsDeterministic(path, signals); + const contradictionShot = plan.shots.find((_, i) => path.beats[i].kind === 'contradiction'); + expect(contradictionShot).toBeDefined(); + expect(contradictionShot!.stormMode).toBe('contradiction'); + expect(contradictionShot!.cut).toBe('hard_cut'); + expect(contradictionShot!.dutch).toBeGreaterThan(0); + expect(contradictionShot!.scoreCue).toBe('minor_drop'); + }); + + it('ends on a crane pull-back with a major resolve', () => { + const nodes = Array.from({ length: 5 }, (_, i) => makeNode({ id: `m${i}` })); + const edges = nodes.slice(1).map((n, i) => makeEdge(`m${i}`, n.id, { weight: 0.6 })); + const path = planCinemaPath(nodes, edges, 'm0'); + const signals = computeSignals(nodes, edges); + const plan = planShotsDeterministic(path, signals); + const last = plan.shots[plan.shots.length - 1]; + expect(last.move).toBe('crane'); + expect(last.scoreCue).toBe('major_resolve'); + }); + + it('is deterministic — same inputs yield the same plan', () => { + const nodes = Array.from({ length: 6 }, (_, i) => makeNode({ id: `d${i}` })); + const edges = [makeEdge('d0', 'd1', { weight: 0.8 }), makeEdge('d1', 'd2', { weight: 0.5 })]; + const path = planCinemaPath(nodes, edges, 'd0'); + const sig = computeSignals(nodes, edges); + const p1 = planShotsDeterministic(path, sig); + const p2 = planShotsDeterministic(path, sig); + expect(p1.shots.map((s) => s.move)).toEqual(p2.shots.map((s) => s.move)); + expect(p1.logline).toBe(p2.logline); + }); +}); + +describe('topology — graph signals', () => { + beforeEach(() => resetNodeCounter()); + + it('computes betweenness, clusters, and peak keystone on a real shape', () => { + // Two clusters bridged by 'hub' → hub has the highest betweenness. + const hub = makeNode({ id: 'hub' }); + const l1 = makeNode({ id: 'l1' }); + const l2 = makeNode({ id: 'l2' }); + const r1 = makeNode({ id: 'r1' }); + const r2 = makeNode({ id: 'r2' }); + const edges = [ + makeEdge('l1', 'l2'), + makeEdge('l2', 'hub'), + makeEdge('hub', 'r1'), + makeEdge('r1', 'r2'), + ]; + const sig = computeSignals([hub, l1, l2, r1, r2], edges); + expect(sig.peakBetweennessId).toBe('hub'); + expect(sig.nodes.get('hub')!.betweenness).toBeGreaterThan(sig.nodes.get('l1')!.betweenness); + expect(sig.clusterCount).toBe(1); // all connected through hub + // All signals are finite and in range. + for (const s of sig.nodes.values()) { + expect(s.betweenness).toBeGreaterThanOrEqual(0); + expect(s.betweenness).toBeLessThanOrEqual(1); + expect(Number.isFinite(s.recencyRank)).toBe(true); + } + }); + + it('flags contradiction edges and computes surprise in range', () => { + const a = makeNode({ id: 'a' }); + const b = makeNode({ id: 'b' }); + const edges = [makeEdge('a', 'b', { weight: 0.1, type: 'contradiction' })]; + const sig = computeSignals([a, b], edges); + expect(sig.edges[0].isContradiction).toBe(true); + expect(sig.edges[0].surprise).toBeGreaterThanOrEqual(0); + expect(sig.edges[0].surprise).toBeLessThanOrEqual(1); + }); +}); diff --git a/apps/dashboard/src/lib/graph/cinema/auteur.ts b/apps/dashboard/src/lib/graph/cinema/auteur.ts new file mode 100644 index 0000000..e2334bf --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/auteur.ts @@ -0,0 +1,223 @@ +// The Auteur — the director's brain + the typed shot-plan contract. +// +// The LLM (Tier 1) or the deterministic rule table (Tier 2) produces a +// DirectorPlan: a sequence of cinematographic Shots, one per CinemaBeat, each +// grounded in a real node and justified by a real graph metric. The camera +// runtime (director.ts) executes it. Carry-forward semantics mean a sparse or +// half-hallucinated plan ALWAYS resolves to a coherent film — the same +// robustness pattern as narrator.resolveNarration. + +import type { CinemaPath, CinemaBeat } from './pathfinder'; +import type { GraphSignals } from './topology'; + +// ── Camera grammar (string unions keep LLM output validatable) ─────────────── +export type Move = 'push_in' | 'pull_back' | 'orbit' | 'crane' | 'whip_pan' | 'rack_focus' | 'hold'; +export type Angle = 'eye' | 'low' | 'high'; // low = look up (power); high = look down (decay) +export type Cut = 'fly' | 'hard_cut' | 'match_cut'; +export type StormMode = 'anchor' | 'connection' | 'contradiction' | 'surprise'; +export type CaptionTone = 'curious' | 'tense' | 'resolved' | 'awe' | 'neutral'; +export type ScoreCue = 'motif' | 'minor_drop' | 'major_resolve' | 'silence'; +export type Act = 'I' | 'II' | 'III'; +export type EmotionalArc = 'man_in_hole' | 'rags_to_riches' | 'icarus' | 'cinderella' | 'oedipus' | 'flat'; +export type DirectorSource = 'backend-llm' | 'on-device' | 'deterministic'; + +/** A directed shot. Only axes that CHANGE need be set — the rest carry forward + * from the previous resolved shot (ultimate default = today's camera constants). */ +export interface Shot { + nodeId: string; // MUST cite a real node (alignment key + grounding constraint) + move?: Move; + angle?: Angle; + dutch?: number; // camera roll, radians, 0..~0.5 + standoff?: number; // world units + flightSeconds?: number; + dwellSeconds?: number; + halflife?: number; // spring smoothing; 0 = jump-cut + cut?: Cut; + stormMode?: StormMode; + intensity?: number; // 0..1 → scales the ignition spike + tension?: number; // 0..1 master scalar + act?: Act; + tone?: CaptionTone; + scoreCue?: ScoreCue; + why: string; // REQUIRED: cites the real metric driving this shot + viaEdgeKey?: string; // `${source}->${target}` for two-node framing +} + +export interface DirectorPlan { + source: DirectorSource; + logline: string; + arc: EmotionalArc; + shots: Shot[]; +} + +/** Every axis filled after carry-forward — what the director reads each beat. */ +export type ResolvedShot = Required> & { viaEdgeKey?: string }; + +// Ultimate defaults — today's hardcoded camera constants, so a plan-less or +// fully-sparse run is byte-identical to the pre-Auteur camera. +export const SHOT_DEFAULTS: Omit = { + move: 'hold', + angle: 'eye', + dutch: 0, + standoff: 26, + flightSeconds: 2.4, + dwellSeconds: 3.2, + halflife: 0.35, + cut: 'fly', + stormMode: 'connection', + intensity: 0.7, + tension: 0.3, + act: 'I', + tone: 'neutral', + scoreCue: 'motif', +}; + +const MOVES: ReadonlySet = new Set(['push_in', 'pull_back', 'orbit', 'crane', 'whip_pan', 'rack_focus', 'hold']); +const ANGLES: ReadonlySet = new Set(['eye', 'low', 'high']); +const CUTS: ReadonlySet = new Set(['fly', 'hard_cut', 'match_cut']); +const STORM_MODES: ReadonlySet = new Set(['anchor', 'connection', 'contradiction', 'surprise']); +const TONES: ReadonlySet = new Set(['curious', 'tense', 'resolved', 'awe', 'neutral']); +const SCORE_CUES: ReadonlySet = new Set(['motif', 'minor_drop', 'major_resolve', 'silence']); +const ACTS: ReadonlySet = new Set(['I', 'II', 'III']); + +function num(v: unknown, lo: number, hi: number, fallback: number): number { + const n = typeof v === 'number' && Number.isFinite(v) ? v : NaN; + if (Number.isNaN(n)) return fallback; + return Math.max(lo, Math.min(hi, n)); +} +function pick(v: unknown, set: ReadonlySet, fallback: T): T { + return typeof v === 'string' && set.has(v as T) ? (v as T) : fallback; +} + +/** + * Resolve a DirectorPlan into one fully-specified ResolvedShot per beat. + * Aligns by nodeId; every unspecified/garbage axis is back-filled by carry-forward + * (previous shot → SHOT_DEFAULTS). A shot can NEVER be blank or invalid. + */ +export function resolveShots(plan: DirectorPlan | null, path: CinemaPath): ResolvedShot[] { + const byNode = new Map(); + for (const s of plan?.shots ?? []) { + if (s && typeof s.nodeId === 'string') byNode.set(s.nodeId, s); + } + const resolved: ResolvedShot[] = []; + let prev: ResolvedShot | null = null; + for (const beat of path.beats) { + const raw = byNode.get(beat.nodeId); + const base = prev ?? { ...SHOT_DEFAULTS, nodeId: beat.nodeId, why: '' }; + const shot: ResolvedShot = { + nodeId: beat.nodeId, + move: pick(raw?.move, MOVES, base.move), + angle: pick(raw?.angle, ANGLES, base.angle), + dutch: num(raw?.dutch, 0, 0.6, base.dutch), + standoff: num(raw?.standoff, 8, 90, base.standoff), + flightSeconds: num(raw?.flightSeconds, 0.4, 6, base.flightSeconds), + dwellSeconds: num(raw?.dwellSeconds, 0.6, 8, base.dwellSeconds), + halflife: num(raw?.halflife, 0, 1.5, base.halflife), + cut: pick(raw?.cut, CUTS, 'fly'), // cut never carries forward — default per beat + stormMode: pick(raw?.stormMode, STORM_MODES, base.stormMode), + intensity: num(raw?.intensity, 0, 1, base.intensity), + tension: num(raw?.tension, 0, 1, base.tension), + act: pick(raw?.act, ACTS, base.act), + tone: pick(raw?.tone, TONES, base.tone), + scoreCue: pick(raw?.scoreCue, SCORE_CUES, 'motif'), + why: typeof raw?.why === 'string' && raw.why.trim() ? raw.why : base.why || 'establishing shot', + viaEdgeKey: typeof raw?.viaEdgeKey === 'string' ? raw.viaEdgeKey : undefined, + }; + resolved.push(shot); + prev = shot; + } + return resolved; +} + +// ── The deterministic auteur (Tier 2) ──────────────────────────────────────── +// The graph-metric → shot-grammar rule table. This SAME table is handed to the +// LLM as its system prompt (see directorSystemPrompt), so Tier-1 output is +// directly comparable to and back-fillable against this baseline. + +function actFor(progress: number): Act { + return progress < 0.34 ? 'I' : progress < 0.72 ? 'II' : 'III'; +} + +/** + * Produce a cinematic DirectorPlan from pure graph signals — no LLM. This alone + * ships the hero film: every shot is grounded and justified by a real metric. + */ +export function planShotsDeterministic(path: CinemaPath, signals: GraphSignals): DirectorPlan { + const n = path.beats.length; + const shots: Shot[] = path.beats.map((beat, i) => { + const progress = n > 1 ? i / (n - 1) : 0; + const act = actFor(progress); + const sig = signals.nodes.get(beat.nodeId); + const isPeak = beat.nodeId === signals.peakBetweennessId; + const isFinale = i === n - 1; + const isOrigin = i === 0; + + // Default shot for a plain connection beat. + let shot: Shot = { + nodeId: beat.nodeId, + move: 'push_in', + angle: 'eye', + cut: 'fly', + stormMode: 'connection', + tone: 'curious', + scoreCue: 'motif', + act, + intensity: 0.6, + tension: 0.3, + why: 'a connected memory', + }; + + if (isOrigin) { + shot = { ...shot, move: 'push_in', tone: 'curious', tension: 0.25, stormMode: 'anchor', why: 'opening on the focal memory' }; + } + // High-betweenness keystone → reverent low-angle slow orbit. + if (isPeak || (sig && sig.betweenness > 0.6)) { + shot = { ...shot, move: 'orbit', angle: 'low', stormMode: 'anchor', intensity: 0.75, tension: 0.45, tone: 'awe', why: 'low-angle orbit — the most load-bearing memory in the graph' }; + } + // Contradiction → Dutch push-in, hard cut, crimson chaos, minor drop. + if (beat.kind === 'contradiction') { + shot = { ...shot, move: 'push_in', angle: 'eye', dutch: 0.28, cut: 'hard_cut', stormMode: 'contradiction', intensity: 1, tension: 0.95, tone: 'tense', scoreCue: 'minor_drop', viaEdgeKey: beat.viaEdge ? `${beat.viaEdge.source}->${beat.viaEdge.target}` : undefined, why: 'two memories in tension — a Dutch two-shot collision' }; + } + // Surprise edge → gold/violet convergence, rising awe. + if (beat.kind === 'surprise') { + shot = { ...shot, move: 'orbit', stormMode: 'surprise', intensity: 0.85, tension: 0.6, tone: 'awe', scoreCue: 'motif', why: 'a surprising, distant-but-plausible connection' }; + } + // Fading memory → drifting high angle. + if (sig && (sig.retention < 0.35 || sig.suppression > 0.5)) { + shot = { ...shot, angle: 'high', move: 'pull_back', tone: 'neutral', intensity: 0.4, why: 'a fading memory — high-angle drift' }; + } + // Recent → the "now" beat. + if (beat.kind === 'recent') { + shot = { ...shot, move: 'push_in', tone: 'resolved', tension: 0.4, why: 'where the memory is now' }; + } + // Finale → crane pull-back, major resolve. + if (isFinale) { + shot = { ...shot, move: 'crane', cut: 'fly', stormMode: 'anchor', tone: 'awe', tension: 0.5, scoreCue: 'major_resolve', why: 'crane pull-back over the whole cluster — resolution' }; + } + return shot; + }); + + const arc: EmotionalArc = path.beats.some((b) => b.kind === 'contradiction') ? 'man_in_hole' : 'rags_to_riches'; + const originLabel = path.beats[0]?.node.label ?? 'a memory'; + const logline = `A short film about ${originLabel} — ${n} shots through the graph${arc === 'man_in_hole' ? ', through a contradiction and out the other side' : ''}.`; + + return { source: 'deterministic', logline, arc, shots }; +} + +/** The rule table as an LLM system prompt — keeps Tier-1 output comparable to + * the Tier-2 baseline (and thus back-fillable by resolveShots). */ +export function directorSystemPrompt(): string { + return [ + 'You are a film director shooting a short documentary about an AI\'s own memory graph.', + 'Output a DirectorPlan: a logline, an emotional arc, and one shot per beat.', + 'Each shot MUST cite a real nodeId and a real "why" referencing a graph metric.', + 'Grammar → meaning:', + '- high betweenness (load-bearing memory) → low-angle slow orbit, reverent', + '- contradiction edge → Dutch angle + push_in + hard_cut + crimson storm + minor_drop score', + '- surprising distant link → gold/violet orbit→stream convergence + awe', + '- merge/supersede → match_cut at identical standoff+angle (same idea)', + '- low retention / high suppression → high-angle drift (fading)', + '- finale → crane pull_back + major_resolve', + 'Build a real emotional arc across acts I→II→III. Only specify axes that change.', + ].join('\n'); +} diff --git a/apps/dashboard/src/lib/graph/cinema/narrator.ts b/apps/dashboard/src/lib/graph/cinema/narrator.ts index 8210cbe..7e3d429 100644 --- a/apps/dashboard/src/lib/graph/cinema/narrator.ts +++ b/apps/dashboard/src/lib/graph/cinema/narrator.ts @@ -35,6 +35,7 @@ const KIND_CHIP = { contradiction: 'Tension', recent: 'Now', bridge: 'Jump', + surprise: 'Surprise', } satisfies Record; function snippet(content: string, max = 90): string { diff --git a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts index 0c4cd20..4e8a457 100644 --- a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts +++ b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts @@ -23,7 +23,7 @@ export interface CinemaBeat { /** Edge traversed to arrive here (null for the opening beat). */ viaEdge: GraphEdge | null; /** Why this beat exists — drives the deterministic caption + visual emphasis. */ - kind: 'origin' | 'connection' | 'contradiction' | 'recent' | 'bridge'; + kind: 'origin' | 'connection' | 'contradiction' | 'recent' | 'bridge' | 'surprise'; /** 0..1 emphasis used by the sandbox to spike emissive/bloom on arrival. */ intensity: number; } @@ -40,11 +40,11 @@ export interface CinemaPath { flowEdges: GraphEdge[]; } -interface Adjacency { +export interface Adjacency { [nodeId: string]: { edge: GraphEdge; otherId: string }[]; } -function buildAdjacency(edges: GraphEdge[]): Adjacency { +export function buildAdjacency(edges: GraphEdge[]): Adjacency { const adj: Adjacency = {}; for (const edge of edges) { (adj[edge.source] ??= []).push({ edge, otherId: edge.target }); @@ -57,12 +57,12 @@ function buildAdjacency(edges: GraphEdge[]): Adjacency { return adj; } -function isContradictionEdge(edge: GraphEdge): boolean { +export function isContradictionEdge(edge: GraphEdge): boolean { const t = (edge.type ?? '').toLowerCase(); return t.includes('contradict') || t.includes('conflict') || t.includes('supersede'); } -function recencyOf(node: GraphNode): number { +export function recencyOf(node: GraphNode): number { // Larger = more recent. Tolerates missing/invalid timestamps. const t = Date.parse(node.updatedAt || node.createdAt || ''); return Number.isFinite(t) ? t : 0; diff --git a/apps/dashboard/src/lib/graph/cinema/topology.ts b/apps/dashboard/src/lib/graph/cinema/topology.ts new file mode 100644 index 0000000..5518b39 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/topology.ts @@ -0,0 +1,225 @@ +// The Auteur — graph signal extraction. +// +// Pure, dependency-free statistics over the REAL /api/graph data, computed once +// per Cinema launch. These signals are what gives the AI director something +// meaningful to direct: which memory is most load-bearing (betweenness), where +// tension lives (contradictions), what's surprising (distant-but-plausible +// links), what's fading (low retention / suppression). No LLM, no WebGPU, no +// network — fully headless-testable. + +import type { GraphNode, GraphEdge } from '$types'; +import { buildAdjacency, recencyOf, isContradictionEdge } from './pathfinder'; + +export interface NodeSignal { + nodeId: string; + /** Raw connection count. */ + degree: number; + /** Brandes betweenness centrality, normalized 0..1 — how load-bearing this + * memory is as a bridge between clusters. The director favors high-betweenness + * nodes for hero shots. */ + betweenness: number; + /** Connected-component id (which cluster of memory this belongs to). */ + clusterId: number; + /** 0..1, 1 = most recent. */ + recencyRank: number; + /** FSRS retention 0..1. */ + retention: number; + /** Suppression pressure 0..1 (memory actively being forgotten). */ + suppression: number; +} + +export interface EdgeSignal { + source: string; + target: string; + isContradiction: boolean; + isMergeSupersede: boolean; + /** 0..1: high when endpoints share neighbors (plausible) yet the edge weight + * is low (distant) — a surprising, non-obvious connection. */ + surprise: number; + weight: number; +} + +export interface GraphSignals { + nodes: Map; + edges: EdgeSignal[]; + clusterCount: number; + /** Node id with the single highest betweenness — the graph's keystone. */ + peakBetweennessId: string; +} + +function isMergeSupersedeEdge(edge: GraphEdge): boolean { + const t = (edge.type ?? '').toLowerCase(); + return t.includes('merge') || t.includes('supersede') || t.includes('duplicate'); +} + +/** + * Brandes' algorithm for betweenness centrality on an unweighted, undirected + * graph. O(V·E) — fine for /api/graph payloads. Returns raw (unnormalized) + * scores keyed by node id; the caller normalizes. + */ +function brandesBetweenness(nodeIds: string[], adj: Record): Map { + const cb = new Map(); + for (const v of nodeIds) cb.set(v, 0); + + for (const s of nodeIds) { + const stack: string[] = []; + const pred = new Map(); + const sigma = new Map(); + const dist = new Map(); + for (const v of nodeIds) { + pred.set(v, []); + sigma.set(v, 0); + dist.set(v, -1); + } + sigma.set(s, 1); + dist.set(s, 0); + + // BFS (unweighted shortest paths). + const queue: string[] = [s]; + let head = 0; + while (head < queue.length) { + const v = queue[head++]; + stack.push(v); + for (const { otherId: w } of adj[v] ?? []) { + if ((dist.get(w) ?? -1) < 0) { + dist.set(w, (dist.get(v) ?? 0) + 1); + queue.push(w); + } + if ((dist.get(w) ?? -1) === (dist.get(v) ?? 0) + 1) { + sigma.set(w, (sigma.get(w) ?? 0) + (sigma.get(v) ?? 0)); + pred.get(w)!.push(v); + } + } + } + + // Accumulation (back-propagate dependencies). + const delta = new Map(); + for (const v of nodeIds) delta.set(v, 0); + while (stack.length > 0) { + const w = stack.pop()!; + for (const v of pred.get(w) ?? []) { + const c = ((sigma.get(v) ?? 0) / (sigma.get(w) || 1)) * (1 + (delta.get(w) ?? 0)); + delta.set(v, (delta.get(v) ?? 0) + c); + } + if (w !== s) cb.set(w, (cb.get(w) ?? 0) + (delta.get(w) ?? 0)); + } + } + return cb; +} + +/** Union-find connected components → a cluster id per node. */ +function components(nodeIds: string[], edges: GraphEdge[]): { clusterOf: Map; count: number } { + const parent = new Map(); + for (const id of nodeIds) parent.set(id, id); + const find = (x: string): string => { + let root = x; + while (parent.get(root) !== root) root = parent.get(root)!; + // Path compression. + let cur = x; + while (parent.get(cur) !== root) { + const next = parent.get(cur)!; + parent.set(cur, root); + cur = next; + } + return root; + }; + const union = (a: string, b: string) => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(ra, rb); + }; + for (const e of edges) { + if (parent.has(e.source) && parent.has(e.target)) union(e.source, e.target); + } + const rootToCluster = new Map(); + const clusterOf = new Map(); + let next = 0; + for (const id of nodeIds) { + const r = find(id); + if (!rootToCluster.has(r)) rootToCluster.set(r, next++); + clusterOf.set(id, rootToCluster.get(r)!); + } + return { clusterOf, count: next }; +} + +/** + * Compute all director signals from the real graph. Pure; safe to call once at + * launch. Caps betweenness work on very large graphs by limiting to the + * top-degree subset (the only nodes that can carry meaningful centrality). + */ +export function computeSignals(nodes: GraphNode[], edges: GraphEdge[]): GraphSignals { + const nodeIds = nodes.map((n) => n.id); + const adj = buildAdjacency(edges); + + // Recency ranking (0..1, 1 = newest). + const byRecency = [...nodes].sort((a, b) => recencyOf(a) - recencyOf(b)); + const recencyRank = new Map(); + byRecency.forEach((n, i) => recencyRank.set(n.id, nodes.length > 1 ? i / (nodes.length - 1) : 1)); + + // Betweenness — guard pathological sizes: above the cap, compute on the + // top-degree subset (others get 0; they can't be meaningful bridges anyway). + const BETWEENNESS_CAP = 600; + let betweennessNodes = nodeIds; + if (nodeIds.length > BETWEENNESS_CAP) { + betweennessNodes = [...nodeIds] + .sort((a, b) => (adj[b]?.length ?? 0) - (adj[a]?.length ?? 0)) + .slice(0, BETWEENNESS_CAP); + } + const rawBetween = brandesBetweenness(betweennessNodes, adj); + let maxBetween = 0; + for (const v of rawBetween.values()) maxBetween = Math.max(maxBetween, v); + + const { clusterOf, count: clusterCount } = components(nodeIds, edges); + + const maxSuppression = Math.max(1, ...nodes.map((n) => n.suppression_count ?? 0)); + + const nodeSignals = new Map(); + let peakBetweennessId = nodeIds[0] ?? ''; + let peakVal = -1; + for (const n of nodes) { + const bt = maxBetween > 0 ? (rawBetween.get(n.id) ?? 0) / maxBetween : 0; + if (bt > peakVal) { + peakVal = bt; + peakBetweennessId = n.id; + } + nodeSignals.set(n.id, { + nodeId: n.id, + degree: adj[n.id]?.length ?? 0, + betweenness: bt, + clusterId: clusterOf.get(n.id) ?? 0, + recencyRank: recencyRank.get(n.id) ?? 0, + retention: clamp01(n.retention ?? 0), + suppression: clamp01((n.suppression_count ?? 0) / maxSuppression), + }); + } + + // Edge signals incl. surprise (shared-neighbor overlap × edge distance). + const neighborSets = new Map>(); + for (const id of nodeIds) neighborSets.set(id, new Set((adj[id] ?? []).map((a) => a.otherId))); + const edgeSignals: EdgeSignal[] = edges.map((e) => { + const a = neighborSets.get(e.source); + const b = neighborSets.get(e.target); + let shared = 0; + if (a && b) { + const [small, large] = a.size < b.size ? [a, b] : [b, a]; + for (const x of small) if (large.has(x)) shared++; + } + const union = (a?.size ?? 0) + (b?.size ?? 0) - shared || 1; + const overlap = shared / union; // Jaccard: structural plausibility. + const distance = 1 - clamp01(e.weight ?? 0); // low weight = semantically distant. + return { + source: e.source, + target: e.target, + isContradiction: isContradictionEdge(e), + isMergeSupersede: isMergeSupersedeEdge(e), + surprise: clamp01(overlap * distance * 2), // plausible AND distant = surprising. + weight: e.weight ?? 0, + }; + }); + + return { nodes: nodeSignals, edges: edgeSignals, clusterCount, peakBetweennessId }; +} + +function clamp01(x: number): number { + return Math.max(0, Math.min(1, Number.isFinite(x) ? x : 0)); +} From 6345e66e4a271de644fd7a91f048c6ff3719e01a Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 00:58:35 -0500 Subject: [PATCH 08/28] =?UTF-8?q?feat(auteur):=20Phase=202=20=E2=80=94=20d?= =?UTF-8?q?irector=20executes=20the=20screenplay=20(shippable=20hero)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit director.ts: optional shots:ResolvedShot[] in DirectorOptions; per-beat flight/dwell timing; framePosition now reads move (push_in/pull_back/crane scale standoff) + angle (low=look-up, high=look-down) + standoff; orbit shots revolve the camera during dwell; Dutch roll via camera.up; hard/match cuts snap (editorial cut). With NO shots the camera is byte-identical to before (all values fall back to the existing constants + easeInOutCubic lerp). MemoryCinema.svelte: build computeSignals + planShotsDeterministic + resolveShots on launch, pass shots to the director; onBeat drives storm mode + director's note + Act + tension from the shot. New UI: pre-roll DIRECTOR'S PLAN card (logline naming real memories), per-beat 'why this shot' note, Act I/II/III badge, tension-tinted progress bar, Auteur source badge. The deterministic auteur ships the hero film with zero LLM. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/MemoryCinema.svelte | 131 ++++++++++++++++-- .../src/lib/graph/cinema/director.ts | 117 +++++++++++++--- 2 files changed, 216 insertions(+), 32 deletions(-) diff --git a/apps/dashboard/src/lib/components/MemoryCinema.svelte b/apps/dashboard/src/lib/components/MemoryCinema.svelte index 547f9fd..38712d3 100644 --- a/apps/dashboard/src/lib/components/MemoryCinema.svelte +++ b/apps/dashboard/src/lib/components/MemoryCinema.svelte @@ -22,6 +22,14 @@ type CinemaNarration, type BeatNarration, } from '$lib/graph/cinema/narrator'; + import { computeSignals } from '$lib/graph/cinema/topology'; + import { + planShotsDeterministic, + resolveShots, + type DirectorPlan, + type ResolvedShot, + type StormMode, + } from '$lib/graph/cinema/auteur'; import type { SemanticRole } from '$lib/graph/cinema/storm'; import type { CinemaSandbox } from '$lib/graph/cinema/sandbox'; @@ -46,6 +54,12 @@ let voiceOn = $state(false); let localAiOn = $state(false); let statusLine = $state(''); + // Auteur (director) state surfaced in the overlay. + let directorNote = $state(''); // the current shot's "why" (cites a real metric) + let act = $state<'I' | 'II' | 'III'>('I'); + let tension = $state(0); // 0..1 for the tension sparkline + let logline = $state(''); + let plan = $state(null); let canvasHost = $state(undefined); let sandbox: CinemaSandbox | null = null; @@ -82,11 +96,6 @@ return pos; } - function roleFor(beat: CinemaBeat): SemanticRole { - if (beat.kind === 'origin') return 'anchor'; - if (beat.kind === 'contradiction') return 'contradiction'; - return 'connection'; - } function speak(text: string) { if (!voiceOn || typeof speechSynthesis === 'undefined') return; @@ -119,15 +128,30 @@ }, 18); } - function onBeat(beat: CinemaBeat, index: number) { + // Map the director's StormMode to the storm runtime's SemanticRole. 'surprise' + // is a Phase-3 storm mode; until then it reads as 'connection'. + function stormRole(mode: StormMode): SemanticRole { + return mode === 'surprise' ? 'connection' : mode; + } + + function onBeat(beat: CinemaBeat, index: number, shot: ResolvedShot | null) { beatIndex = index + 1; const text = narration?.beats[index]?.text ?? beat.node.label ?? ''; chip = narration?.beats[index]?.chip ?? ''; streamCaption(text); speak(text); + // Surface the director's intent for this shot — the "why", act, tension. + if (shot) { + directorNote = shot.why; + act = shot.act; + tension = shot.tension; + } if (sandbox && webgpuActive) { const wp = currentPositions?.get(beat.nodeId); - if (wp) sandbox.transitionTo(roleFor(beat), wp); + if (wp) { + const mode: StormMode = shot?.stormMode ?? 'connection'; + sandbox.transitionTo(stormRole(mode), wp); + } } } @@ -143,6 +167,11 @@ director = null; narration = null; renderFailures = 0; + directorNote = ''; + logline = ''; + plan = null; + act = 'I'; + tension = 0; open = true; stage = 'planning'; @@ -162,6 +191,15 @@ } currentPositions = layoutPositions(path); + // THE AUTEUR: read the graph's dramatic structure and direct the film. + // Tier 2 (deterministic) ships the hero; Tier 1 (LLM) lands in Phase 4. + const signals = computeSignals(nodes, edges); + plan = planShotsDeterministic(path, signals); + logline = plan.logline; + const shots = resolveShots(plan, path); + act = shots[0]?.act ?? 'I'; + tension = shots[0]?.tension ?? 0; + // Tiers 1/2: resolve narration (backend LLM → local captions). narration = await resolveNarration(path, localAiOn ? localAiFetcher() : fetchBackendNarration); narrationSource = narration.source; @@ -197,7 +235,7 @@ stage = 'done'; statusLine = 'End of tour.'; }, - }, { reducedMotion }); + }, { reducedMotion, shots }); stage = 'playing'; statusLine = webgpuActive @@ -340,10 +378,16 @@
{statusLine} + {#if plan} + + {plan.source === 'deterministic' ? 'Auteur (local)' : 'Auteur (AI)'} + + {/if} {#if narrationSource} {narrationSource === 'backend-llm' ? 'AI narration' : 'Live captions'} {/if} {#if webgpuActive}WebGPU{/if} + {#if stage === 'playing'}Act {act}{/if}
- + + {#if stage === 'planning' && logline} +
+
Director's plan
+

{logline}

+
+ {/if} + +
{#if chip}
{chip}
{/if}

{caption}

+ {#if directorNote && stage === 'playing'} +

▸ {directorNote}

+ {/if}
{#if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if} @@ -406,6 +464,50 @@ border-color: rgba(20, 232, 198, 0.5); color: #14e8c6; } + .cinema-act { + font-size: 0.6rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-dream-glow); + opacity: 0.85; + } + /* Pre-roll director's plan card — centered, the AI's statement of intent. */ + .cinema-plan-card { + position: absolute; + z-index: 3; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 520px; + padding: 1.5rem 1.75rem; + border-radius: 16px; + text-align: center; + animation: cinema-plan-in 0.5s ease both; + } + @keyframes cinema-plan-in { + from { opacity: 0; transform: translate(-50%, -46%); } + to { opacity: 1; transform: translate(-50%, -50%); } + } + .cinema-plan-kicker { + font-size: 0.65rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-synapse-glow); + margin-bottom: 0.5rem; + } + .cinema-plan-logline { + font-size: clamp(1.05rem, 2.2vw, 1.4rem); + line-height: 1.5; + color: var(--color-bright); + margin: 0; + } + .cinema-note { + font-size: 0.78rem; + color: var(--color-synapse-glow); + opacity: 0.85; + margin: 0 0 0.6rem; + font-style: italic; + } .cinema-dot { width: 8px; height: 8px; @@ -464,8 +566,13 @@ } .cinema-progress-fill { height: 100%; - background: linear-gradient(90deg, var(--color-synapse), var(--color-dream)); - transition: width 0.2s linear; + /* Tint shifts toward crimson as the shot's tension rises (--tension 0..1). */ + background: linear-gradient( + 90deg, + var(--color-synapse), + color-mix(in oklch, var(--color-dream), #ff2d55 calc(var(--tension, 0) * 100%)) + ); + transition: width 0.2s linear, background 0.4s ease; } .cinema-beatcount { margin-top: 0.4rem; diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts index 7a3d3c3..8681f88 100644 --- a/apps/dashboard/src/lib/graph/cinema/director.ts +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -11,10 +11,12 @@ import * as THREE from 'three'; import type { CinemaPath, CinemaBeat } from './pathfinder'; +import type { ResolvedShot } from './auteur'; export interface DirectorCallbacks { - /** Fired once when the camera arrives at (or cuts to) a beat. */ - onBeat?: (beat: CinemaBeat, index: number) => void; + /** Fired once when the camera arrives at (or cuts to) a beat. The resolved + * shot for the beat is passed so consumers can drive storm/score/captions. */ + onBeat?: (beat: CinemaBeat, index: number, shot: ResolvedShot | null) => void; /** Fired when the whole tour finishes. */ onComplete?: () => void; /** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */ @@ -30,6 +32,11 @@ export interface DirectorOptions { standoff?: number; /** Instant cuts instead of flights (prefers-reduced-motion). */ reducedMotion?: boolean; + /** Optional per-beat director's plan (one ResolvedShot per beat, aligned by + * index). When ABSENT the camera behaves byte-identically to the pre-Auteur + * director — every value falls back to the constants above. When present, + * each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */ + shots?: ResolvedShot[]; } type Phase = 'idle' | 'flying' | 'dwelling' | 'done'; @@ -73,9 +80,25 @@ export class CinemaDirector { dwellSeconds: opts.dwellSeconds ?? 3.2, standoff: opts.standoff ?? 26, reducedMotion: opts.reducedMotion ?? false, + shots: opts.shots ?? [], }; } + /** The resolved shot directing a beat, or null when no plan was supplied + * (→ the camera uses the constant defaults = pre-Auteur behavior). */ + private shotAt(index: number): ResolvedShot | null { + return this.opts.shots[index] ?? null; + } + + /** Per-beat flight duration: the shot's value, else the global default. A + * hard/match cut has zero flight (handled in beginFlightTo). */ + private flightSecondsAt(index: number): number { + return this.shotAt(index)?.flightSeconds ?? this.opts.flightSeconds; + } + private dwellSecondsAt(index: number): number { + return this.shotAt(index)?.dwellSeconds ?? this.opts.dwellSeconds; + } + get totalBeats(): number { return this.path.beats.length; } @@ -99,39 +122,61 @@ export class CinemaDirector { this.phase = 'done'; } - /** Compute the camera stand-off position for a beat's node. */ - private framePosition(beat: CinemaBeat, out: THREE.Vector3): THREE.Vector3 { + /** Compute the camera stand-off position for a beat's node, directed by its + * shot (move / angle / standoff). With no shot, reproduces the original + * framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */ + private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 { const nodePos = this.positions.get(beat.nodeId); if (!nodePos) { // Node has no resolved position yet — keep current framing. return out.copy(this.camera.position); } - // Offset back + up from the node along the current view direction so the - // node sits centered with a cinematic slightly-above angle. + const shot = this.shotAt(index); + _tmpDir.copy(this.camera.position).sub(nodePos); if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1); _tmpDir.normalize(); - // Bias the approach vector upward a touch for a filmic tilt. - _tmpDir.addScaledVector(_tmpUp, 0.35).normalize(); - return out.copy(nodePos).addScaledVector(_tmpDir, this.opts.standoff); + + // Vertical bias = the camera angle. Default +0.35 (slightly above, the + // original filmic tilt). low = look UP at the node (power), high = look + // DOWN (decay/fading). + let upBias = 0.35; + if (shot) { + if (shot.angle === 'low') upBias = -0.45; + else if (shot.angle === 'high') upBias = 0.7; + } + _tmpDir.addScaledVector(_tmpUp, upBias).normalize(); + + // Stand-off = how close: push_in tightens, pull_back/crane widen. + let standoff = shot?.standoff ?? this.opts.standoff; + if (shot) { + if (shot.move === 'push_in') standoff *= 0.7; + else if (shot.move === 'pull_back') standoff *= 1.5; + else if (shot.move === 'crane') standoff *= 1.8; + } + return out.copy(nodePos).addScaledVector(_tmpDir, standoff); } private beginFlightTo(index: number): void { const beat = this.path.beats[index]; const nodePos = this.positions.get(beat.nodeId); + const shot = this.shotAt(index); this.fromPos.copy(this.camera.position); this.fromTarget.copy(this.target); - this.framePosition(beat, this.toPos); + this.framePosition(beat, index, this.toPos); this.toTarget.copy(nodePos ?? this.target); this.phaseElapsed = 0; - if (this.opts.reducedMotion) { - // Jump-cut: snap, fire the beat, go straight to dwelling. + // A directed hard/match cut snaps instantly (like reduced-motion), so the + // editorial "cut" reads as an edit, not a fly. reduced-motion forces this + // for every beat regardless of shot. + const snap = this.opts.reducedMotion || shot?.cut === 'hard_cut' || shot?.cut === 'match_cut'; + if (snap) { this.camera.position.copy(this.toPos); this.target.copy(this.toTarget); this.phase = 'dwelling'; - this.cb.onBeat?.(beat, index); + this.cb.onBeat?.(beat, index, shot); } else { this.phase = 'flying'; } @@ -144,23 +189,33 @@ export class CinemaDirector { const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); this.phaseElapsed += dt; + const flightSecs = this.flightSecondsAt(this.beatIndex); + const dwellSecs = this.dwellSecondsAt(this.beatIndex); + if (this.phase === 'flying') { - const t = Math.min(1, this.phaseElapsed / this.opts.flightSeconds); + const t = Math.min(1, this.phaseElapsed / flightSecs); const e = easeInOutCubic(t); this.camera.position.lerpVectors(this.fromPos, this.toPos, e); this.target.lerpVectors(this.fromTarget, this.toTarget, e); + this.applyDutch(this.beatIndex, e); if (t >= 1) { this.phase = 'dwelling'; this.phaseElapsed = 0; - this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex); + this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex, this.shotAt(this.beatIndex)); } } else if (this.phase === 'dwelling') { - // Gentle drift during the dwell keeps the shot alive (skipped if reduced). if (!this.opts.reducedMotion) { const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId); - if (nodePos) this.target.lerp(nodePos, 0.02); + if (nodePos) { + this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive + // An orbit shot slowly revolves the camera around the node + // during the dwell — the signature "reverent" move for keystones. + if (this.shotAt(this.beatIndex)?.move === 'orbit') { + this.orbitAround(nodePos, dt * 0.35); + } + } } - if (this.phaseElapsed >= this.opts.dwellSeconds) { + if (this.phaseElapsed >= dwellSecs) { const nextIndex = this.beatIndex + 1; if (nextIndex >= this.path.beats.length) { this.phase = 'done'; @@ -178,10 +233,32 @@ export class CinemaDirector { const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0; const intra = this.phase === 'flying' - ? Math.min(1, this.phaseElapsed / this.opts.flightSeconds) * 0.5 - : 0.5 + Math.min(1, this.phaseElapsed / this.opts.dwellSeconds) * 0.5; + ? Math.min(1, this.phaseElapsed / flightSecs) * 0.5 + : 0.5 + Math.min(1, this.phaseElapsed / dwellSecs) * 0.5; this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per)); } + + /** Revolve the camera around a node by `angle` radians (orbit shots). */ + private orbitAround(center: THREE.Vector3, angle: number): void { + _tmpDir.copy(this.camera.position).sub(center); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = _tmpDir.x * cos - _tmpDir.z * sin; + const z = _tmpDir.x * sin + _tmpDir.z * cos; + _tmpDir.x = x; + _tmpDir.z = z; + this.camera.position.copy(center).add(_tmpDir); + } + + /** Roll the camera (Dutch angle) toward the shot's target roll over the + * flight, easing back to upright for non-Dutch shots. */ + private applyDutch(index: number, t: number): void { + const targetRoll = this.shotAt(index)?.dutch ?? 0; + const roll = targetRoll * t; + // camera.up = rotate world-up around the camera's forward axis by `roll`. + _tmpDir.set(0, 0, -1).applyQuaternion(this.camera.quaternion); // forward + this.camera.up.set(0, 1, 0).applyAxisAngle(_tmpDir, roll); + } } function easeInOutCubic(t: number): number { From fbb38e4abbb53d2f4b158e90c0acef8eacd807ae Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:09:07 -0500 Subject: [PATCH 09/28] =?UTF-8?q?fix(cinema):=20guarantee=20the=20storm=20?= =?UTF-8?q?stays=20centered=20=E2=80=94=20never=20flies=20off-screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: layoutPositions grew per beat (radius 22 + i*6), so each beat sat farther out; the camera + storm marched off into space as the tour progressed. Fix (centered-by-construction): - layoutPositions: tight BOUNDED golden-angle shell (SHELL_RADIUS 14), no growth. - sandbox: storm pinned to the WORLD ORIGIN permanently; camera hard-clamped to an 18-46 unit distance band and always lookAt(origin); containment sphere sized to the FOV at origin. A runaway move is corrected every frame. - director: new centerOnOrigin mode (enabled when WebGPU active) — frames/orbits the origin instead of flying to scattered nodes; variety from angle/standoff. No path remains for the subject to leave frame. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/MemoryCinema.svelte | 23 +++++++--- .../src/lib/graph/cinema/director.ts | 24 ++++++++--- .../dashboard/src/lib/graph/cinema/sandbox.ts | 43 +++++++++++++------ 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/lib/components/MemoryCinema.svelte b/apps/dashboard/src/lib/components/MemoryCinema.svelte index 38712d3..68bb823 100644 --- a/apps/dashboard/src/lib/components/MemoryCinema.svelte +++ b/apps/dashboard/src/lib/components/MemoryCinema.svelte @@ -78,18 +78,29 @@ // Deterministic layout: spread path nodes on a gentle spiral so the camera // has distinct world positions to fly between (independent of the WebGL // graph's internal coordinates — keeps the sandbox isolated). + // Lay the beat nodes out on a TIGHT, BOUNDED shell centered on the origin — + // fixed radius, no per-beat growth. Earlier this grew (22 + i*6) so each beat + // sat farther out and the camera+storm marched off into space ("flying off"). + // A bounded shell keeps the whole composition centered; cinematic variety + // comes from the camera angle/move/standoff, not from translating across a + // huge volume. The focused node is always re-centered by recenterOn() below. + const SHELL_RADIUS = 14; function layoutPositions(p: CinemaPath): Map { const pos = new Map(); const n = p.beats.length; for (let i = 0; i < n; i++) { - const angle = (i / Math.max(1, n)) * Math.PI * 2 * 1.4; - const radius = 22 + i * 6; + // Distribute beats evenly on a sphere (golden-angle spiral) so they + // never clump and never exceed SHELL_RADIUS from center. + const t = n > 1 ? i / (n - 1) : 0.5; + const y = 1 - t * 2; // 1..-1 + const r = Math.sqrt(Math.max(0, 1 - y * y)); + const theta = i * 2.399963; // golden angle pos.set( p.beats[i].nodeId, new THREE.Vector3( - Math.cos(angle) * radius, - (i % 2 === 0 ? 1 : -1) * (4 + i * 2), - Math.sin(angle) * radius + Math.cos(theta) * r * SHELL_RADIUS, + y * SHELL_RADIUS * 0.5, + Math.sin(theta) * r * SHELL_RADIUS ) ); } @@ -235,7 +246,7 @@ stage = 'done'; statusLine = 'End of tour.'; }, - }, { reducedMotion, shots }); + }, { reducedMotion, shots, centerOnOrigin: webgpuActive }); stage = 'playing'; statusLine = webgpuActive diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts index 8681f88..6252cbc 100644 --- a/apps/dashboard/src/lib/graph/cinema/director.ts +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -37,12 +37,18 @@ export interface DirectorOptions { * director — every value falls back to the constants above. When present, * each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */ shots?: ResolvedShot[]; + /** When true, the camera frames the WORLD ORIGIN every shot (the WebGPU storm + * is pinned there) instead of flying out to scattered node positions — so the + * subject is ALWAYS centered and can never fly off-screen. Camera variety + * comes purely from angle/standoff/orbit. Used by the WebGPU sandbox path. */ + centerOnOrigin?: boolean; } type Phase = 'idle' | 'flying' | 'dwelling' | 'done'; const _tmpDir = new THREE.Vector3(); const _tmpUp = new THREE.Vector3(0, 1, 0); +const _origin = new THREE.Vector3(0, 0, 0); export class CinemaDirector { private camera: THREE.PerspectiveCamera; @@ -81,6 +87,7 @@ export class CinemaDirector { standoff: opts.standoff ?? 26, reducedMotion: opts.reducedMotion ?? false, shots: opts.shots ?? [], + centerOnOrigin: opts.centerOnOrigin ?? false, }; } @@ -122,11 +129,18 @@ export class CinemaDirector { this.phase = 'done'; } - /** Compute the camera stand-off position for a beat's node, directed by its - * shot (move / angle / standoff). With no shot, reproduces the original + /** The focal point a beat frames: the world ORIGIN in centered mode (storm is + * pinned there), else the node's laid-out position. */ + private focalPoint(beat: CinemaBeat): THREE.Vector3 | null { + if (this.opts.centerOnOrigin) return _origin; + return this.positions.get(beat.nodeId) ?? null; + } + + /** Compute the camera stand-off position for a beat's focal point, directed by + * its shot (move / angle / standoff). With no shot, reproduces the original * framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */ private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 { - const nodePos = this.positions.get(beat.nodeId); + const nodePos = this.focalPoint(beat); if (!nodePos) { // Node has no resolved position yet — keep current framing. return out.copy(this.camera.position); @@ -159,7 +173,7 @@ export class CinemaDirector { private beginFlightTo(index: number): void { const beat = this.path.beats[index]; - const nodePos = this.positions.get(beat.nodeId); + const nodePos = this.focalPoint(beat); const shot = this.shotAt(index); this.fromPos.copy(this.camera.position); @@ -205,7 +219,7 @@ export class CinemaDirector { } } else if (this.phase === 'dwelling') { if (!this.opts.reducedMotion) { - const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId); + const nodePos = this.focalPoint(this.path.beats[this.beatIndex]); if (nodePos) { this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive // An orbit shot slowly revolves the camera around the node diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index e3ab3f7..eec67d4 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -13,6 +13,12 @@ import * as THREE from 'three'; import type { SemanticRole, SemanticComputeStorm } from './storm'; +// The storm lives at the world origin, permanently. The camera always looks here +// and is clamped to a safe distance band so the subject can never leave frame. +const ORIGIN = new THREE.Vector3(0, 0, 0); +const MIN_CAM_DIST = 18; +const MAX_CAM_DIST = 46; + export function isWebGPUSupported(): boolean { return typeof navigator !== 'undefined' && 'gpu' in navigator; } @@ -153,26 +159,37 @@ export class CinemaSandbox { this.booted = true; } - /** Retarget the storm + look the camera at the beat (called by the director). */ - transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void { + /** Retarget the storm's MODE/ignition for a beat. The storm is permanently + * centered at the WORLD ORIGIN (see render) so it is always dead-center in + * frame — worldPos here only conveys which node, not where the storm sits. */ + transitionTo(role: SemanticRole, _worldPos: THREE.Vector3): void { if (!this.booted) return; - this.storm.transitionTo(role, worldPos); + this.storm.transitionTo(role, ORIGIN); } - /** Render one frame. Camera is driven externally (director mutates position/target). - * A single frame's failure must not crash the tour — it's caught and surfaced - * via a thrown error the caller already handles (drops to camera-only). */ + /** Render one frame. The storm is pinned to the origin and the camera always + * looks at the origin, so the storm CANNOT leave the frame. The director + * varies only the camera's orbital position/angle (set via cameraRef), and we + * clamp that to a safe distance band here as a final guarantee. */ async render(deltaSeconds: number): Promise { if (!this.booted) return; - this.camera.lookAt(this.target); - // Keep the storm inside the frame: derive the largest world radius that - // fully fits the camera's vertical FOV at the current distance to target, - // minus a margin so the glow halo stays on-screen too. The storm clamps - // itself to this each frame, so it reframes as the camera flies. - const dist = this.camera.position.distanceTo(this.target); + // Hard guarantee: clamp the camera into a distance band from origin so a + // runaway director move can never push the subject out of view, then look + // dead at the origin where the storm lives. + const distToOrigin = this.camera.position.length(); + if (distToOrigin < MIN_CAM_DIST || distToOrigin > MAX_CAM_DIST || !Number.isFinite(distToOrigin)) { + const d = Math.min(MAX_CAM_DIST, Math.max(MIN_CAM_DIST, distToOrigin || MAX_CAM_DIST)); + if (distToOrigin > 1e-3) this.camera.position.setLength(d); + else this.camera.position.set(0, 12, d); + } + this.camera.lookAt(ORIGIN); + + // Size the containment sphere to the camera's FOV at the origin so the + // storm always fully fits the frame with margin. + const dist = this.camera.position.length(); const vfov = (this.camera.fov * Math.PI) / 180; - const fitRadius = Math.tan(vfov / 2) * dist * 0.62; // 0.62 = on-screen margin + const fitRadius = Math.tan(vfov / 2) * dist * 0.55; this.storm.setContainRadius(fitRadius); await this.storm.update(deltaSeconds); From 889dc4ea11d0ce7a01547053f1e705107c0f1368 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:15:12 -0500 Subject: [PATCH 10/28] feat(cinema): radial containment spring + INSANE iridescent rainbow storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the runaway ring (screenshot showed an expanding ellipse clipping the frame): orbital mode added tangential velocity with nothing pulling particles to a target radius, so they spiraled outward. Now a two-sided RADIAL SPRING pulls every particle toward an in-frame shell (containRadius*0.62) with a per-particle band so the cloud is a contained breathing sphere, not an ever-growing ring. Tighter velocity clamp + boundary snap as belt-and-suspenders. Color: replaced the flat 3-color tint with a living iridescent RAINBOW — hue drifts by per-particle phase + radius + time + a global rotating hue shift (fract/abs hexagon palette). Dramatic beats blend their mode color over the rainbow (crimson at contradictions, gold at surprises) via uModeTintAmt; calm beats stay mostly rainbow. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/lib/graph/cinema/storm.ts | 105 +++++++++++++------ 1 file changed, 72 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index bab0807..08bae39 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -40,6 +40,8 @@ import { clamp, min, mix, + fract, + abs, positionLocal, } from 'three/tsl'; @@ -95,6 +97,10 @@ export class SemanticComputeStorm { // a spring force back so the storm NEVER flies off-screen. Sized to the // camera framing by the sandbox via setContainRadius(). private uContainRadius = uniform(48); + // Global hue rotation (advances over time) + how strongly the beat's mode + // tint overrides the rainbow (0 = full rainbow, 1 = full mode color). + private uHueShift = uniform(0); + private uModeTintAmt = uniform(0.25); constructor( renderer: { computeAsync: (node: ComputeDispatch) => Promise }, @@ -123,7 +129,7 @@ export class SemanticComputeStorm { this.bufferPhase = bufferPhase; this.buildCompute(bufferPos, bufferVel, bufferPhase); - this.buildRender(bufferPos); + this.buildRender(bufferPos, bufferPhase); } private buildCompute( @@ -143,9 +149,10 @@ export class SemanticComputeStorm { const toTarget = vec3(this.uTarget).sub(pos); // Mode 0 — Anchor: orbital swirl (tangential velocity around target). + // Gentler than before so particles revolve rather than fling outward. const orbital = vec3(toTarget.z, float(0), toTarget.x.negate()) .normalize() - .mul(0.05); + .mul(0.03); // Mode 1 — Connection: stream toward target + per-particle wave. const wave = vec3( @@ -173,38 +180,41 @@ export class SemanticComputeStorm { // Ignition shockwave yanks particles toward the new node on each beat. vel.addAssign(toTarget.normalize().mul(this.uIgnition.mul(0.02))); - // CONTAINMENT: soft spherical boundary around the focused target so the - // storm NEVER escapes the camera frame. Past uContainRadius a spring - // force pulls each particle back toward the target; the force ramps in - // smoothly (smoothstep-like) so the boundary reads as a glowing - // membrane, not a hard wall. The chaos attractor (Rössler) is - // unbounded by nature — this is what keeps mode 2 on-screen. - const distFromTarget = length(toTarget.negate()); // |pos - target| - const overflow = distFromTarget.sub(this.uContainRadius).max(0); - const pullBack = clamp(overflow.mul(0.012), 0, 0.6); - vel.addAssign(toTarget.normalize().mul(pullBack)); + // RADIAL CONTAINMENT SPRING — the real fix for the runaway ring. + // Every particle is pulled toward a TARGET SHELL radius (a fraction of + // the contain radius), so the cloud forms a contained, breathing sphere + // instead of spiraling outward into an ever-bigger ring that clips the + // frame. The spring is two-sided: pulls IN when too far, pushes OUT when + // collapsed to the center, giving the storm volume without escape. + const distFromTarget = length(toTarget.negate()); + const shellR = this.uContainRadius.mul(0.62); // comfortable in-frame shell + const radialErr = distFromTarget.sub(shellR); // + = outside, - = inside + // Per-particle phase varies each particle's preferred shell a touch so + // they spread across a thick band, not a razor-thin ring. + const band = sin(phase.mul(3.0).add(this.uTime.mul(0.3))).mul(shellR.mul(0.18)); + const towardShell = toTarget.normalize().mul(radialErr.sub(band).mul(0.06)); + vel.addAssign(towardShell); - // Hard velocity clamp so no single step can shoot a particle across - // the frame even at peak ignition / chaos divergence. + // Hard velocity clamp so no single step can shoot a particle far. const speed = length(vel); - const maxSpeed = float(1.2); + const maxSpeed = float(0.9); vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001)))); pos.addAssign(vel); - vel.mulAssign(0.95); + vel.mulAssign(0.94); - // Final safety net: if a particle still ends up beyond 1.35x the - // radius (extreme edge case), snap it onto the boundary shell so it - // can never be lost off-screen. + // Final hard safety net: clamp any particle that still ends up past the + // contain radius back onto the boundary shell — guarantees nothing can + // ever be off-screen, even mid chaos divergence. const finalToTarget = vec3(this.uTarget).sub(pos); const finalDist = length(finalToTarget.negate()); - const hardR = this.uContainRadius.mul(1.35); + const hardR = this.uContainRadius; const snapped = vec3(this.uTarget).sub(finalToTarget.normalize().mul(hardR)); pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0)))); })().compute(this.count); } - private buildRender(bufferPos: StorageBufferAttribute): void { + private buildRender(bufferPos: StorageBufferAttribute, bufferPhase: StorageBufferAttribute): void { // SpriteNodeMaterial: emissive routed to bloom; additive against the void. const mat = new SpriteNodeMaterial({ transparent: true, @@ -218,22 +228,45 @@ export class SemanticComputeStorm { // translated to its computed position. Assigning the bare storage element // to positionNode (without positionLocal) collapses every quad to a point // at its instance origin — the bug the audit caught. + const phaseStore = storage(bufferPhase, 'float', this.count); const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex); mat.positionNode = instancePos.add(positionLocal); mat.colorNode = Fn(() => { - const anchor = vec3(0.0, 1.0, 0.85); // luminescent cyan - const link = vec3(0.2, 0.4, 1.0); // electric royal blue - const contradiction = vec3(1.0, 0.1, 0.3); // crimson neon - const base = select( - this.uMode.equal(0), - anchor, - select(this.uMode.equal(1), link, contradiction) + const pos = instancePos; + const ph = phaseStore.element(instanceIndex); + const radius = length(pos.sub(vec3(this.uTarget))); + + // ── INSANE IRIDESCENT RAINBOW ── + // Hue drifts across the spectrum by per-particle phase + radius shell + + // time, plus a global beat-driven hue shift (uHueShift). Each particle + // is a different color and the whole cloud slowly rotates through the + // rainbow — a living aurora, not a flat tint. + const hue = fract( + ph.mul(0.16) + .add(radius.mul(0.045)) + .add(this.uTime.mul(0.08)) + .add(this.uHueShift) ); - // Brighten on ignition so the beat blazes through the bloom pass. - // The +0.55 floor keeps particles visibly glowing between beats so the - // storm never fades to black once ignition decays. - return base.mul(this.uIgnition.mul(3.0).add(0.55)); + // hue → RGB (classic fract/abs hexagon palette), high saturation. + const r = clamp(abs(hue.mul(6).sub(3)).sub(1), 0, 1); + const g = clamp(float(2).sub(abs(hue.mul(6).sub(2))), 0, 1); + const b = clamp(float(2).sub(abs(hue.mul(6).sub(4))), 0, 1); + const rainbow = vec3(r, g, b); + + // The beat's mode tint (crimson at a contradiction, cyan anchor, etc.) + // is blended IN by uModeTintAmt so dramatic beats still read their color + // while keeping the iridescent shimmer underneath. + const modeTint = select( + this.uMode.equal(2), + vec3(1.0, 0.08, 0.32), // contradiction → crimson + select(this.uMode.equal(3), vec3(1.0, 0.78, 0.1), vec3(0.1, 0.9, 1.0)) // surprise → gold, else cyan + ); + const tinted = mix(rainbow, modeTint, this.uModeTintAmt); + + // Brighten on ignition so beats blaze through the bloom pass; the +0.6 + // floor keeps the rainbow glowing between beats. + return tinted.mul(this.uIgnition.mul(2.4).add(0.6)); })(); // One instanced sprite per particle; positions come from the GPU storage @@ -252,6 +285,8 @@ export class SemanticComputeStorm { async update(deltaSeconds: number): Promise { const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); this.uTime.value += dt; + // Slowly rotate the whole rainbow so the cloud is always shimmering. + this.uHueShift.value = (this.uHueShift.value + dt * 0.05) % 1; // Ignition decays toward 0 between beats (the colorNode floor keeps the // storm glowing); spikes back up on transitionTo(). this.uIgnition.value = Math.max(0, this.uIgnition.value - dt * 2.0); @@ -267,8 +302,12 @@ export class SemanticComputeStorm { /** Fired on each narrative beat: retarget the storm + spike ignition. */ transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void { this.uTarget.value.copy(worldPos); - this.uMode.value = ROLE_MODE[role] ?? 1; + const mode = ROLE_MODE[role] ?? 1; + this.uMode.value = mode; this.uIgnition.value = 8.0; + // Dramatic beats (contradiction=2, surprise=3) push their mode color over + // the rainbow so they read clearly; calm beats stay mostly iridescent. + this.uModeTintAmt.value = mode >= 2 ? 0.7 : 0.22; } /** Size the containment sphere (world units) so the storm always stays in From 68d0628a12ef119b1dc2d62e3ee4970eb9001eaf Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:18:53 -0500 Subject: [PATCH 11/28] fix(cinema): tighten storm framing so the bloom halo never clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rainbow storm looked next-dimensional but still clipped the edges — the additive bloom halo extends each particle's glow well past its geometric radius, so the visible cloud was bigger than the contain sphere. - spawn radius 15 -> 8 (particles start inside the shell, no asymmetric inward yank) - sandbox fitRadius margin 0.55 -> 0.40 (leaves room for the bloom halo) - camera band tightened + pushed farther (30-44) so the contained cloud sits small + centered; director standoff clamped into that band in centerOnOrigin mode so the camera never fights the per-frame clamp (the off-center jump). 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/lib/graph/cinema/director.ts | 5 +++++ apps/dashboard/src/lib/graph/cinema/sandbox.ts | 16 +++++++++++----- apps/dashboard/src/lib/graph/cinema/storm.ts | 4 +++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts index 6252cbc..0891169 100644 --- a/apps/dashboard/src/lib/graph/cinema/director.ts +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -168,6 +168,11 @@ export class CinemaDirector { else if (shot.move === 'pull_back') standoff *= 1.5; else if (shot.move === 'crane') standoff *= 1.8; } + // In centered (WebGPU storm) mode the subject is pinned to the origin and + // the sandbox clamps the camera to a far band. Keep the directed standoff + // INSIDE that band so the camera never fights the clamp (which read as an + // off-center jump) — variety here comes from angle + orbit, not distance. + if (this.opts.centerOnOrigin) standoff = Math.max(31, Math.min(43, standoff)); return out.copy(nodePos).addScaledVector(_tmpDir, standoff); } diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index eec67d4..8c30087 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -16,8 +16,11 @@ import type { SemanticRole, SemanticComputeStorm } from './storm'; // The storm lives at the world origin, permanently. The camera always looks here // and is clamped to a safe distance band so the subject can never leave frame. const ORIGIN = new THREE.Vector3(0, 0, 0); -const MIN_CAM_DIST = 18; -const MAX_CAM_DIST = 46; +// Keep the camera in a narrow, fairly FAR band so the contained storm always +// sits comfortably small and centered in frame (a closer camera makes the cloud +// fill — and spill past — the edges once the bloom halo is added). +const MIN_CAM_DIST = 30; +const MAX_CAM_DIST = 44; export function isWebGPUSupported(): boolean { return typeof navigator !== 'undefined' && 'gpu' in navigator; @@ -185,11 +188,14 @@ export class CinemaSandbox { } this.camera.lookAt(ORIGIN); - // Size the containment sphere to the camera's FOV at the origin so the - // storm always fully fits the frame with margin. + // Size the containment sphere to the camera's VERTICAL FOV at the origin + // (the limiting dimension on a landscape frame). The 0.40 factor leaves + // generous room for the additive BLOOM HALO — each particle's glow spreads + // well beyond its geometric position, so the visible cloud is much larger + // than the radius; an aggressive margin is what actually stops the clip. const dist = this.camera.position.length(); const vfov = (this.camera.fov * Math.PI) / 180; - const fitRadius = Math.tan(vfov / 2) * dist * 0.55; + const fitRadius = Math.tan(vfov / 2) * dist * 0.4; this.storm.setContainRadius(fitRadius); await this.storm.update(deltaSeconds); diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index 08bae39..dfc7a08 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -110,7 +110,9 @@ export class SemanticComputeStorm { this.renderer = renderer; this.scene = scene; this.count = opts.count ?? 150_000; - const spawn = opts.spawnRadius ?? 15; + // Spawn inside the contained zone so particles don't start outside the + // shell and get yanked inward asymmetrically (which read as off-center). + const spawn = opts.spawnRadius ?? 8; const positions = new Float32Array(this.count * 3); const velocities = new Float32Array(this.count * 3); From 2c4a9a4df63d113df6d259d473921e4fd15be132 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:26:39 -0500 Subject: [PATCH 12/28] feat(cinema): fill the frame + true full-spectrum color (no more white-out) Storm was a small ring leaving the canvas empty, and the core blew to white. - FILL: sandbox fitRadius margin 0.40 -> 0.82 so the storm fills most of the frame; particles now target their OWN radius across 0.12r..0.92r (filled volumetric ORB, not a thin ring). - COLOR: brightness was x(ignition*2.4+0.6) = up to x19.8, which + additive blending across 150k sprites clipped every channel to white. Clamp the glow low (0.45 floor, ~1.15 ceil) so the RAINBOW shows as pure spectral color; smaller quads (0.18 -> 0.1) keep particles crisp instead of overlapping to mush; gentler bloom (strength 1.1->0.6, threshold 0->0.35) accents cores rather than washing the cloud. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/src/lib/graph/cinema/sandbox.ts | 13 +++--- apps/dashboard/src/lib/graph/cinema/storm.ts | 42 ++++++++++++------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index 8c30087..1db0b33 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -147,7 +147,9 @@ export class CinemaSandbox { scenePass.setMRT(mrt({ output, emissive })); const outputTex = scenePass.getTextureNode('output'); const emissiveTex = scenePass.getTextureNode('emissive'); - const bloomed = this.deps.bloomMod.bloom(emissiveTex, 1.1, 0.6, 0.0); + // Gentler bloom (strength 0.6, threshold 0.35) so it accents the bright + // cores instead of washing the whole colored cloud to white. + const bloomed = this.deps.bloomMod.bloom(emissiveTex, 0.6, 0.65, 0.35); const post = new this.deps.PostProcessing(renderer); (post as unknown as { outputNode: unknown }).outputNode = ( outputTex as { add: (n: unknown) => unknown } @@ -189,13 +191,12 @@ export class CinemaSandbox { this.camera.lookAt(ORIGIN); // Size the containment sphere to the camera's VERTICAL FOV at the origin - // (the limiting dimension on a landscape frame). The 0.40 factor leaves - // generous room for the additive BLOOM HALO — each particle's glow spreads - // well beyond its geometric position, so the visible cloud is much larger - // than the radius; an aggressive margin is what actually stops the clip. + // (the limiting dimension on a landscape frame). 0.82 lets the storm fill + // most of the frame; the storm's internal shell sits well inside this and + // the hard boundary snap keeps the bloom halo from spilling past the edge. const dist = this.camera.position.length(); const vfov = (this.camera.fov * Math.PI) / 180; - const fitRadius = Math.tan(vfov / 2) * dist * 0.4; + const fitRadius = Math.tan(vfov / 2) * dist * 0.82; this.storm.setContainRadius(fitRadius); await this.storm.update(deltaSeconds); diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index dfc7a08..cf589e7 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -189,12 +189,16 @@ export class SemanticComputeStorm { // frame. The spring is two-sided: pulls IN when too far, pushes OUT when // collapsed to the center, giving the storm volume without escape. const distFromTarget = length(toTarget.negate()); - const shellR = this.uContainRadius.mul(0.62); // comfortable in-frame shell + // Each particle targets its OWN radius spread across the whole interior + // (0.12r .. 0.92r), so the cloud is a FILLED VOLUMETRIC ORB filling the + // frame — not a thin ring. The per-particle preferred radius is a stable + // function of its phase, gently breathing over time. + const prefFrac = float(0.12).add( + abs(sin(phase.mul(1.7).add(this.uTime.mul(0.12)))).mul(0.8) + ); + const shellR = this.uContainRadius.mul(prefFrac); const radialErr = distFromTarget.sub(shellR); // + = outside, - = inside - // Per-particle phase varies each particle's preferred shell a touch so - // they spread across a thick band, not a razor-thin ring. - const band = sin(phase.mul(3.0).add(this.uTime.mul(0.3))).mul(shellR.mul(0.18)); - const towardShell = toTarget.normalize().mul(radialErr.sub(band).mul(0.06)); + const towardShell = toTarget.normalize().mul(radialErr.mul(0.05)); vel.addAssign(towardShell); // Hard velocity clamp so no single step can shoot a particle far. @@ -250,15 +254,17 @@ export class SemanticComputeStorm { .add(this.uTime.mul(0.08)) .add(this.uHueShift) ); - // hue → RGB (classic fract/abs hexagon palette), high saturation. + // hue → RGB (fract/abs hexagon palette). Pull the valleys UP slightly + // then re-saturate so the rainbow is vivid and FULLY saturated (not + // washed) — pure spectral color, never white. const r = clamp(abs(hue.mul(6).sub(3)).sub(1), 0, 1); const g = clamp(float(2).sub(abs(hue.mul(6).sub(2))), 0, 1); const b = clamp(float(2).sub(abs(hue.mul(6).sub(4))), 0, 1); const rainbow = vec3(r, g, b); - // The beat's mode tint (crimson at a contradiction, cyan anchor, etc.) - // is blended IN by uModeTintAmt so dramatic beats still read their color - // while keeping the iridescent shimmer underneath. + // The beat's mode tint (crimson at a contradiction, gold at surprise, + // cyan default) is blended in by uModeTintAmt so dramatic beats read + // their color while keeping the iridescent shimmer underneath. const modeTint = select( this.uMode.equal(2), vec3(1.0, 0.08, 0.32), // contradiction → crimson @@ -266,15 +272,19 @@ export class SemanticComputeStorm { ); const tinted = mix(rainbow, modeTint, this.uModeTintAmt); - // Brighten on ignition so beats blaze through the bloom pass; the +0.6 - // floor keeps the rainbow glowing between beats. - return tinted.mul(this.uIgnition.mul(2.4).add(0.6)); + // Brightness is CLAMPED low so the rainbow shows as COLOR, not white. + // Additive blending across 150k overlapping sprites compounds fast — a + // high multiplier blows the core to pure white (the bug you saw). Keep + // the glow gentle (0.45 floor, +ignition up to ~1.1) and let the + // selective bloom pass do the blooming, not raw over-bright color. + const glow = clamp(this.uIgnition.mul(0.18).add(0.45), 0, 1.15); + return tinted.mul(glow); })(); - // One instanced sprite per particle; positions come from the GPU storage - // buffer via positionNode, so the geometry is a single unit quad and the - // instance count is the particle count. - const geometry = new THREE.PlaneGeometry(0.18, 0.18); + // One instanced sprite per particle. Small quads (0.1) keep individual + // particles as crisp colored points of light rather than overlapping into + // white mush across the now-larger volume. + const geometry = new THREE.PlaneGeometry(0.1, 0.1); const mesh = new THREE.InstancedMesh(geometry, mat as unknown as THREE.Material, this.count); mesh.frustumCulled = false; this.material = mat; From 00f1816111a32c35fcfe5e98a1ebccac5ec0a613 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:37:10 -0500 Subject: [PATCH 13/28] feat(cinema): explode -> pixelate -> reform storm (kill the swirls) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per direction: keep the mind-blowing explosion + pixelation moments, ditch the thin ribbon swirls. Complete physics rewrite: - removed orbital/stream/Rössler modes (the swirls + the off-center drift source) - each particle has a deterministic HOME on a volumetric shell around ORIGIN (centroid anchored — can never drift off-frame again) - uBurst detonation cycle: every beat blows particles radially out (explosion), then a home-spring crystallizes them back (reform); contradictions detonate hardest - PIXELATION: positions snap to a 3D grid that's fine when reformed, dissolved during the burst — the crystalline voxel look - hard velocity + radius clamps so it can never fly off or blow up 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/lib/graph/cinema/storm.ts | 117 ++++++++++--------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index cf589e7..5242988 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -42,6 +42,7 @@ import { mix, fract, abs, + floor, positionLocal, } from 'three/tsl'; @@ -101,6 +102,9 @@ export class SemanticComputeStorm { // tint overrides the rainbow (0 = full rainbow, 1 = full mode color). private uHueShift = uniform(0); private uModeTintAmt = uniform(0.25); + // Detonation cycle: spikes to 1 on each beat (explosion), decays to 0 + // (crystallize/reform). Drives the explode→pixelate→reform look. + private uBurst = uniform(0); constructor( renderer: { computeAsync: (node: ComputeDispatch) => Promise }, @@ -148,74 +152,65 @@ export class SemanticComputeStorm { const vel = velStore.element(instanceIndex); const phase = phaseStore.element(instanceIndex); - const toTarget = vec3(this.uTarget).sub(pos); + // ── EACH PARTICLE'S "HOME" — a deterministic point on a volumetric + // spherical shell around the ORIGIN, derived purely from its phase. + // The cloud reforms to these homes between beats, so the centroid is + // ANCHORED to origin and CANNOT drift (the bug that pushed it off-frame). + // No swirl/orbital/attractor terms — those drew the ugly ribbons. + const a1 = phase.mul(12.9898).sin().mul(43758.5453); + const a2 = phase.mul(78.233).sin().mul(12543.531); + const u = fract(a1); // 0..1 + const v = fract(a2); // 0..1 + const theta = u.mul(6.28318); // azimuth + const phi = v.mul(3.14159); // polar + // Per-particle home radius fills the interior (0.30r..0.95r) for a + // dense volumetric orb rather than a hollow shell. + const homeFrac = float(0.3).add(fract(phase.mul(3.7)).mul(0.65)); + const homeR = this.uContainRadius.mul(homeFrac); + const home = vec3( + sin(phi).mul(cos(theta)), + cos(phi), + sin(phi).mul(sin(theta)) + ).mul(homeR); // centered on origin (uTarget is always origin in sandbox) - // Mode 0 — Anchor: orbital swirl (tangential velocity around target). - // Gentler than before so particles revolve rather than fling outward. - const orbital = vec3(toTarget.z, float(0), toTarget.x.negate()) - .normalize() - .mul(0.03); + // ── DETONATION: on each beat uBurst≈1 → blow particles radially OUT + // from origin (the explosion in photo 2). Strength scales with the + // particle's own radius so the burst is a full-volume shockwave. + const outDir = pos.normalize(); + vel.addAssign(outDir.mul(this.uBurst.mul(0.9))); - // Mode 1 — Connection: stream toward target + per-particle wave. - const wave = vec3( - sin(this.uTime.add(phase)).mul(0.02), - cos(this.uTime.add(phase)).mul(0.02), - sin(this.uTime.mul(1.5).add(phase)).mul(0.02) - ); - const stream = toTarget.normalize().mul(0.08).add(wave); + // ── REFORM: a spring pulling each particle back to its home. As uBurst + // decays the spring wins, crystallizing the explosion back into the orb. + const toHome = home.sub(pos); + vel.addAssign(toHome.mul(0.045)); - // Mode 2 — Contradiction: Rössler strange-attractor chaos. - const dt = float(0.01); - const dx = vel.y.negate().sub(vel.z).mul(dt); - const dy = vel.x.add(vel.y.mul(0.2)).mul(dt); - const dz = float(0.2).add(vel.z.mul(vel.x.sub(5.7))).mul(dt); - const chaos = vec3(dx, dy, dz).mul(2.0); + // Subtle living shimmer so the reformed orb breathes (mean-zero, no net + // drift — uses the particle's own home direction, not a global bias). + const shimmer = home.normalize().mul(sin(this.uTime.mul(1.3).add(phase.mul(6.1))).mul(0.015)); + vel.addAssign(shimmer); - // Runtime mode selection (select(), not cond()). - const active = select( - this.uMode.equal(0), - orbital, - select(this.uMode.equal(1), stream, chaos) - ); - - vel.addAssign(active); - // Ignition shockwave yanks particles toward the new node on each beat. - vel.addAssign(toTarget.normalize().mul(this.uIgnition.mul(0.02))); - - // RADIAL CONTAINMENT SPRING — the real fix for the runaway ring. - // Every particle is pulled toward a TARGET SHELL radius (a fraction of - // the contain radius), so the cloud forms a contained, breathing sphere - // instead of spiraling outward into an ever-bigger ring that clips the - // frame. The spring is two-sided: pulls IN when too far, pushes OUT when - // collapsed to the center, giving the storm volume without escape. - const distFromTarget = length(toTarget.negate()); - // Each particle targets its OWN radius spread across the whole interior - // (0.12r .. 0.92r), so the cloud is a FILLED VOLUMETRIC ORB filling the - // frame — not a thin ring. The per-particle preferred radius is a stable - // function of its phase, gently breathing over time. - const prefFrac = float(0.12).add( - abs(sin(phase.mul(1.7).add(this.uTime.mul(0.12)))).mul(0.8) - ); - const shellR = this.uContainRadius.mul(prefFrac); - const radialErr = distFromTarget.sub(shellR); // + = outside, - = inside - const towardShell = toTarget.normalize().mul(radialErr.mul(0.05)); - vel.addAssign(towardShell); - - // Hard velocity clamp so no single step can shoot a particle far. + // Hard velocity clamp — nothing can ever fly off or blow up. const speed = length(vel); - const maxSpeed = float(0.9); + const maxSpeed = float(1.3); vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001)))); pos.addAssign(vel); - vel.mulAssign(0.94); + vel.mulAssign(0.9); // strong damping → crisp crystallization, no overshoot - // Final hard safety net: clamp any particle that still ends up past the - // contain radius back onto the boundary shell — guarantees nothing can - // ever be off-screen, even mid chaos divergence. - const finalToTarget = vec3(this.uTarget).sub(pos); - const finalDist = length(finalToTarget.negate()); + // ── PIXELATION: as particles crystallize (low burst) snap positions to + // a 3D GRID so the cloud resolves into discrete colored voxels — the + // crystalline look of photo 3. The grid is finest when fully reformed + // (burst≈0) and dissolves during the explosion (burst≈1). + const cell = mix(float(0.55), float(6.0), clamp(this.uBurst, 0, 1)); // small cell = fine pixels + const quantized = floor(pos.div(cell)).add(0.5).mul(cell); + const pixelAmt = clamp(float(1).sub(this.uBurst.mul(1.4)), 0, 0.9); + pos.assign(mix(pos, quantized, pixelAmt)); + + // Final hard safety net: clamp anything past the contain radius back + // onto the boundary shell — guarantees nothing is ever off-screen. + const finalDist = length(pos); const hardR = this.uContainRadius; - const snapped = vec3(this.uTarget).sub(finalToTarget.normalize().mul(hardR)); + const snapped = pos.normalize().mul(hardR); pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0)))); })().compute(this.count); } @@ -302,6 +297,9 @@ export class SemanticComputeStorm { // Ignition decays toward 0 between beats (the colorNode floor keeps the // storm glowing); spikes back up on transitionTo(). this.uIgnition.value = Math.max(0, this.uIgnition.value - dt * 2.0); + // Burst decays fast so the explosion crystallizes back within ~1.2s, + // leaving the rest of the beat as a calm pixelated orb. + this.uBurst.value = Math.max(0, this.uBurst.value - dt * 0.85); // Wait for any in-flight compute to finish before queuing the next. if (this.computeInFlight) await this.computeInFlight; @@ -317,6 +315,9 @@ export class SemanticComputeStorm { const mode = ROLE_MODE[role] ?? 1; this.uMode.value = mode; this.uIgnition.value = 8.0; + // DETONATE: every beat explodes the orb, then it crystallizes/pixelates + // back. Contradictions detonate hardest. + this.uBurst.value = mode === 2 ? 1.0 : 0.8; // Dramatic beats (contradiction=2, surprise=3) push their mode color over // the rainbow so they read clearly; calm beats stay mostly iridescent. this.uModeTintAmt.value = mode >= 2 ? 0.7 : 0.22; From a4c43a652297642ae276431e1c27eb2a219506f2 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 02:58:26 -0500 Subject: [PATCH 14/28] =?UTF-8?q?feat(dashboard):=20alive=20overhaul=20?= =?UTF-8?q?=E2=80=94=20unique=20icons,=20dropdowns,=20motion=20on=20every?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the dashboard feel alive every second, with clear controls, for the July 14 HN relaunch. Memory Cinema is left fully untouched (zero changes to MemoryCinema.svelte / graph/cinema/*; its tests still pass). Foundation (lifts every page): - Icon.svelte: inline-SVG icon system, zero runtime dep. A UNIQUE semantic silhouette per nav item — kills the old duplicated Unicode glyphs (◎◈◉◷ were each reused across multiple items). Wired into sidebar, mobile nav, command palette, logo. - Dropdown.svelte: accessible, keyboard-nav, type-ahead, animated select replacement with color dots / badges. Replaces dead native
@@ -236,8 +254,8 @@ {#if loading}
-
-

Computing activation...

+
+

Computing activation…

{:else if errorMessage} @@ -250,8 +268,8 @@
{:else if !focusedSource && searched}
-
-
+
+

No matching memory

Nothing in the graph matches @@ -263,8 +281,8 @@

{:else if !focusedSource}
-
-
+
+

Waiting for activation

Seed a burst with the search bar above, or enable live mode to diff --git a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte index 13ee04e..4415f0e 100644 --- a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte +++ b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte @@ -1,5 +1,10 @@

-

Explore Connections

+
{#each (['associations', 'chains', 'bridges'] as const) as m} @@ -144,29 +154,51 @@ {#if sourceMemory} {#if loading} -
-
-

Exploring {mode}...

+
+
+ + Exploring {mode}… +
+
+ {#each Array(4) as _, i} +
+
+
+
+
+
+
+ {/each} +
{:else if associations.length > 0}
-

{associations.length} Connections Found

+

+ + Connections Found +

- {#each associations as assoc, i} -
-
- {i + 1} -
-
-

{assoc.content}

-
- {#if assoc.nodeType}{assoc.nodeType}{/if} - {#if assoc.score}Score: {Number(assoc.score).toFixed(3)}{/if} - {#if assoc.similarity}Similarity: {Number(assoc.similarity).toFixed(3)}{/if} - {#if assoc.retention}{(Number(assoc.retention) * 100).toFixed(0)}% retention{/if} - {#if assoc.connectionType}{assoc.connectionType}{/if} + {#each associations as assoc, i (i)} +
+
+
+ {i + 1} +
+
+

{assoc.content}

+
+ {#if assoc.nodeType}{assoc.nodeType}{/if} + {#if assoc.score}Score: {Number(assoc.score).toFixed(3)}{/if} + {#if assoc.similarity}Similarity: {Number(assoc.similarity).toFixed(3)}{/if} + {#if assoc.retention}{(Number(assoc.retention) * 100).toFixed(0)}% retention{/if} + {#if assoc.connectionType}{assoc.connectionType}{/if} +
@@ -174,16 +206,26 @@
{:else} -
-
-

No connections found for this query.

+
+ +

No connections surfaced yet

+

+ {#if mode === 'associations'} + This memory hasn't formed strong links here. Try a broader source query — the graph rewards more general seeds. + {:else} + No {mode} found between these two memories. Pick a different source or target and the path may light up. + {/if} +

{/if} {/if}
-

Importance Scorer

+

+ + Importance Scorer +

4-channel neuroscience scoring: novelty, arousal, reward, attention