diff --git a/apps/dashboard/src/lib/components/Graph3D.svelte b/apps/dashboard/src/lib/components/Graph3D.svelte index 3f88868..062763a 100644 --- a/apps/dashboard/src/lib/components/Graph3D.svelte +++ b/apps/dashboard/src/lib/components/Graph3D.svelte @@ -121,9 +121,23 @@ if (ctx) disposeScene(ctx); }); + // 120Hz Governor. All physics and effect counters are frame-based + // (orb.age++, forceSim.tick, materialization frames). On a ProMotion + // display the browser drives rAF at 120 FPS, which would double-speed + // every ritual. Clamping to ~60 FPS keeps the visual timing identical + // across displays without rewriting every counter to use delta time. + // The `- (dt % 16)` carry avoids long-term drift. + let govLastTime = 0; + function animate() { animationId = requestAnimationFrame(animate); - const time = performance.now() * 0.001; + const now = performance.now(); + if (govLastTime === 0) govLastTime = now; + const dt = now - govLastTime; + if (dt < 16) return; + govLastTime = now - (dt % 16); + + const time = now * 0.001; // Force simulation forceSim.tick(edges); @@ -174,6 +188,20 @@ fresh.push(e); } if (fresh.length === 0) return; + + // Event Horizon Guard. If the last-processed reference fell off the + // end of the capped array (burst of >MAX_EVENTS events in one tick), + // the walk above consumed the ENTIRE buffer — we'd try to animate + // 200 simultaneous births and melt the GPU. Detect the overflow and + // drop this batch on the floor; state is already current via + // lastProcessedEvent pointing forward. + if (fresh.length === events.length && events.length >= 200) { + // eslint-disable-next-line no-console + console.warn('[vestige] Event horizon overflow: dropping visuals for', fresh.length, 'events'); + lastProcessedEvent = events[0]; + return; + } + lastProcessedEvent = events[0]; const mutationCtx: GraphMutationContext = { diff --git a/apps/dashboard/src/lib/components/InsightToast.svelte b/apps/dashboard/src/lib/components/InsightToast.svelte index eb55576..f941911 100644 --- a/apps/dashboard/src/lib/components/InsightToast.svelte +++ b/apps/dashboard/src/lib/components/InsightToast.svelte @@ -50,6 +50,10 @@ aria-label="{t.title}: {t.body}. Click to dismiss." onclick={() => handleClick(t)} onkeydown={(e) => handleKey(e, t)} + onmouseenter={() => toasts.pauseDwell(t.id, t.dwellMs)} + onmouseleave={() => toasts.resumeDwell(t.id)} + onfocus={() => toasts.pauseDwell(t.id, t.dwellMs)} + onblur={() => toasts.resumeDwell(t.id)} style="--toast-color: {t.color}; --toast-dwell: {t.dwellMs}ms;" > @@ -196,6 +200,13 @@ animation: toast-progress var(--toast-dwell) linear forwards; } + /* Hover Panic — freeze auto-dismiss while the user is engaged. + * Pairs with toasts.pauseDwell/resumeDwell on the JS side. */ + .toast-item:hover .toast-progress-fill, + .toast-item:focus-visible .toast-progress-fill { + animation-play-state: paused; + } + @keyframes toast-in { from { opacity: 0; diff --git a/apps/dashboard/src/lib/graph/effects.ts b/apps/dashboard/src/lib/graph/effects.ts index df4c21f..ccae736 100644 --- a/apps/dashboard/src/lib/graph/effects.ts +++ b/apps/dashboard/src/lib/graph/effects.ts @@ -70,6 +70,11 @@ interface BirthOrb { /** Last known target position. If the target disappears mid-flight we keep * using this snapshot so the orb still lands somewhere sensible. */ lastTargetPos: THREE.Vector3; + /** v2.3: Sanhedrin-Shatter state. Set true when getTargetPos returns + * undefined after gestation — the Stop hook deleted the target node + * mid-ritual, so we short-circuit the arrival cascade and implode + * the orb in place as the "cognitive immune system" visual. */ + aborted: boolean; } export class EffectManager { @@ -334,6 +339,7 @@ export class EffectManager { arriveFired: false, onArrive, lastTargetPos: initialTarget, + aborted: false, }); } @@ -547,14 +553,29 @@ export class EffectManager { orb.age++; const totalFrames = orb.gestationFrames + orb.flightFrames; - // Refresh the live target snapshot — if the node still exists we - // track it, otherwise we fall back to the last known position. - const live = orb.getTargetPos(); - if (live) orb.lastTargetPos.copy(live); - const haloMat = orb.sprite.material as THREE.SpriteMaterial; const coreMat = orb.core.material as THREE.SpriteMaterial; + // Refresh the live target snapshot. If the target getter returns + // undefined DURING flight (not just at spawn), the node was + // aborted mid-ritual — typically a Sanhedrin veto deleting a + // hallucination node while the orb was still in transit. Trigger + // the anti-birth: turn red, implode in place, stop tracking. + const live = orb.getTargetPos(); + if (live) { + orb.lastTargetPos.copy(live); + } else if (orb.age > orb.gestationFrames && !orb.aborted) { + orb.aborted = true; + // Fire an implosion where the orb currently is, then splice + // out on the next tick by jumping age to the end of life. + const pos = orb.sprite.position; + haloMat.color.setRGB(1.0, 0.15, 0.2); // blood red + coreMat.color.setRGB(1.0, 0.6, 0.6); + this.createImplosion(pos, new THREE.Color(0xff2533)); + orb.arriveFired = true; + orb.age = totalFrames + 1; + } + if (orb.age <= orb.gestationFrames) { // Gestation phase — pulse brighter + grow from a tiny spark // into a full orb. Sits still at the cosmic center. @@ -573,27 +594,37 @@ export class EffectManager { orb.sprite.position.copy(orb.startPos); orb.core.position.copy(orb.startPos); } else if (orb.age <= totalFrames) { - // Flight phase — Bezier along a dynamic curve. Arc the - // control point upward for a shooting-star trajectory. + // Flight phase — inline quadratic Bezier eval. Zero-alloc: + // no new Vector3 or QuadraticBezierCurve3 per frame, which + // would flood the GC when several orbs are in flight. const t = (orb.age - orb.gestationFrames) / orb.flightFrames; const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOutQuad - const target = orb.lastTargetPos; - const control = new THREE.Vector3() - .addVectors(orb.startPos, target) - .multiplyScalar(0.5); - control.y += 30 + target.distanceTo(orb.startPos) * 0.15; + const s = orb.startPos; + const tgt = orb.lastTargetPos; + const dx = tgt.x - s.x; + const dy = tgt.y - s.y; + const dz = tgt.z - s.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + const cx = (s.x + tgt.x) * 0.5; + const cy = (s.y + tgt.y) * 0.5 + 30 + dist * 0.15; + const cz = (s.z + tgt.z) * 0.5; - const curve = new THREE.QuadraticBezierCurve3(orb.startPos, control, target); - const p = curve.getPoint(ease); - orb.sprite.position.copy(p); - orb.core.position.copy(p); + const oneMinusE = 1 - ease; + const w0 = oneMinusE * oneMinusE; + const w1 = 2 * oneMinusE * ease; + const w2 = ease * ease; + const px = w0 * s.x + w1 * cx + w2 * tgt.x; + const py = w0 * s.y + w1 * cy + w2 * tgt.y; + const pz = w0 * s.z + w1 * cz + w2 * tgt.z; + + orb.sprite.position.set(px, py, pz); + orb.core.position.set(px, py, pz); // Trail effect — shrink + brighten as it approaches target - const flightProgress = ease; - const shrink = 1 - flightProgress * 0.35; + const shrink = 1 - ease * 0.35; orb.sprite.scale.setScalar(5 * shrink); orb.core.scale.setScalar(2 * shrink); haloMat.opacity = 0.95; diff --git a/apps/dashboard/src/lib/graph/events.ts b/apps/dashboard/src/lib/graph/events.ts index 2a992dd..31fd3cc 100644 --- a/apps/dashboard/src/lib/graph/events.ts +++ b/apps/dashboard/src/lib/graph/events.ts @@ -125,12 +125,12 @@ export function mapEventToEffects( // Find spawn position near related nodes const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions); - // Add to all managers NOW so the force simulation can start - // settling the target position during the birth orb's flight. - // NodeManager materialises from scale 0 via easeOutElastic, so the - // node grows in as the orb descends onto it — visually the orb - // looks like it pulls the memory into existence on contact. - const pos = nodeManager.addNode(newNode, spawnPos); + // Reserve the physics slot but hide the node until the orb docks. + // `isBirthRitual:true` skips the 30-frame materialization push, so + // the mesh/glow/label stay invisible; `igniteNode` below flips + // visibility and kicks off the elastic scale-up AT the exact + // millisecond the orb lands — not 100 frames before. + const pos = nodeManager.addNode(newNode, spawnPos, { isBirthRitual: true }); forceSim.addNode(data.id, pos); // FIFO eviction @@ -138,9 +138,9 @@ export function mapEventToEffects( evictOldestLiveNode(ctx, allNodes); // v2.3 Memory Birth Ritual — cosmic-center orb, Bezier flight, - // arrival burst cascade. The old burst/ripple/shockwave cascade - // now fires on arrival at the docking target, not at spawn, so - // the camera's eye has time to track the orb in. + // arrival burst cascade. The burst/ripple/shockwave cascade + // fires on arrival at the docking target, not at spawn, so the + // eye tracks the orb in and the visuals peak on contact. const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1'); const hueShifted = color.clone(); hueShifted.offsetHSL(0.15, 0, 0); @@ -150,20 +150,32 @@ export function mapEventToEffects( color, // Re-resolve the live target position every frame — the node // is being pushed around by the force sim during flight. + // Returning undefined here signals "node was aborted" and + // triggers the Sanhedrin-Shatter anti-birth in effects.ts. () => nodeManager.positions.get(newNode.id), () => { - // Dock. Fire the arrival cascade at the node's current - // position (not the original spawnPos — it has moved). + // Dock. Ignite the node (flips visibility + starts + // materialization) and fire the arrival cascade at the + // node's CURRENT position — the force sim may have moved + // the target during the ritual, so we re-read positions. + nodeManager.igniteNode(newNode.id); const arrivePos = nodeManager.positions.get(newNode.id) ?? spawnPos; + + // Newton's Cradle — kinetic transfer into the graph. + // Bump the mesh scale on impact so the easeOutElastic + // materialization + force-sim springs physically recoil + // instead of the orb docking silently. + const mesh = nodeManager.meshMap.get(newNode.id); + if (mesh) mesh.scale.multiplyScalar(1.8); + effects.createRainbowBurst(arrivePos, color); effects.createShockwave(arrivePos, color, camera); + // Fire BOTH shockwaves immediately (different scales / + // colors for layered crash feel). The previous 166ms + // setTimeout could outlive the scene on route change + // and throw an unhandled rejection. + effects.createShockwave(arrivePos, hueShifted, camera); effects.createRippleWave(arrivePos); - // Second shockwave, hue-shifted, ~166ms delay for a layered - // crash feel rather than a single pop. - setTimeout(() => { - const delayedPos = nodeManager.positions.get(newNode.id) ?? arrivePos; - effects.createShockwave(delayedPos, hueShifted, camera); - }, 166); } ); diff --git a/apps/dashboard/src/lib/graph/nodes.ts b/apps/dashboard/src/lib/graph/nodes.ts index 5a1a9a7..5a5089f 100644 --- a/apps/dashboard/src/lib/graph/nodes.ts +++ b/apps/dashboard/src/lib/graph/nodes.ts @@ -271,7 +271,11 @@ export class NodeManager { return { mesh, glow: sprite, label: labelSprite, size }; } - addNode(node: GraphNode, initialPosition?: THREE.Vector3): THREE.Vector3 { + addNode( + node: GraphNode, + initialPosition?: THREE.Vector3, + options: { isBirthRitual?: boolean } = {} + ): THREE.Vector3 { const pos = initialPosition?.clone() ?? new THREE.Vector3( @@ -289,17 +293,62 @@ export class NodeManager { (glow.material as THREE.SpriteMaterial).opacity = 0; (label.material as THREE.SpriteMaterial).opacity = 0; + if (options.isBirthRitual) { + // v2.3 Birth Ritual: reserve the physics slot but don't show + // anything until the orb docks. Hiding via .visible keeps the + // force simulation + positions map fully active, so getTargetPos() + // can still resolve the live destination for the orb. `igniteNode` + // below flips visibility and kicks off the materialization anim. + mesh.visible = false; + glow.visible = false; + label.visible = false; + mesh.userData.birthRitualPending = { + totalFrames: 30, + targetScale: 0.5 + node.retention * 2, + }; + } else { + this.materializingNodes.push({ + id: node.id, + frame: 0, + totalFrames: 30, + mesh, + glow, + label, + targetScale: 0.5 + node.retention * 2, + }); + } + + return pos; + } + + /** + * v2.3 Birth Ritual docking. Flip visibility and hand the node over to + * the materialization queue so it springs up via easeOutElastic at the + * exact moment the orb hits. No-op if the node wasn't created with + * `isBirthRitual:true` or was already ignited. + */ + igniteNode(id: string) { + const mesh = this.meshMap.get(id); + const glow = this.glowMap.get(id); + const label = this.labelSprites.get(id); + if (!mesh || !glow || !label) return; + const pending = mesh.userData.birthRitualPending as + | { totalFrames: number; targetScale: number } + | undefined; + if (!pending) return; + mesh.visible = true; + glow.visible = true; + label.visible = true; + delete mesh.userData.birthRitualPending; this.materializingNodes.push({ - id: node.id, + id, frame: 0, - totalFrames: 30, + totalFrames: pending.totalFrames, mesh, glow, label, - targetScale: 0.5 + node.retention * 2, + targetScale: pending.targetScale, }); - - return pos; } removeNode(id: string) { diff --git a/apps/dashboard/src/lib/stores/toast.ts b/apps/dashboard/src/lib/stores/toast.ts index c7e58ae..6daef38 100644 --- a/apps/dashboard/src/lib/stores/toast.ts +++ b/apps/dashboard/src/lib/stores/toast.ts @@ -36,6 +36,24 @@ function createToastStore() { let nextId = 1; let lastConnectionAt = 0; + // Dwell-timer registry — exposed so the component can pause on hover + // (biological respect: don't auto-dismiss a toast the user is actively + // reading). Paused entries store remaining ms so resume can schedule a + // new timer for the correct duration. + const dwellTimers = new Map>(); + const dwellPaused = new Map(); + const dwellStart = new Map(); + + function scheduleDismiss(id: number, ms: number) { + dwellStart.set(id, Date.now()); + const handle = setTimeout(() => { + dwellTimers.delete(id); + dwellStart.delete(id); + dismiss(id); + }, ms); + dwellTimers.set(id, handle); + } + function push(toast: Omit) { const id = nextId++; const createdAt = Date.now(); @@ -47,14 +65,43 @@ function createToastStore() { } return next; }); - setTimeout(() => dismiss(id), toast.dwellMs); + scheduleDismiss(id, toast.dwellMs); } function dismiss(id: number) { + const handle = dwellTimers.get(id); + if (handle) { + clearTimeout(handle); + dwellTimers.delete(id); + } + dwellPaused.delete(id); + dwellStart.delete(id); update(list => list.filter(t => t.id !== id)); } + function pauseDwell(id: number, toastDwellMs: number) { + const handle = dwellTimers.get(id); + if (!handle) return; + clearTimeout(handle); + dwellTimers.delete(id); + const startedAt = dwellStart.get(id) ?? Date.now(); + const elapsed = Date.now() - startedAt; + const remaining = Math.max(200, toastDwellMs - elapsed); + dwellPaused.set(id, { remaining }); + } + + function resumeDwell(id: number) { + const paused = dwellPaused.get(id); + if (!paused) return; + dwellPaused.delete(id); + scheduleDismiss(id, paused.remaining); + } + function clear() { + for (const handle of dwellTimers.values()) clearTimeout(handle); + dwellTimers.clear(); + dwellPaused.clear(); + dwellStart.clear(); update(() => []); } @@ -201,25 +248,37 @@ function createToastStore() { } } - // Track the latest processed event by object identity. The websocket store - // prepends new events to its array, so when a new message arrives, the - // first element becomes a new object reference — no IDs or timestamps - // required to detect novelty. + // Track the latest processed event by object identity. The websocket + // store prepends new events (index 0) and caps the array, so we walk + // from the head until we hit a previously-seen event rather than + // comparing only events[0] — which would drop mid-burst events when + // multiple messages arrive in the same Svelte update tick (e.g. a + // swarm epiphany firing DreamCompleted + ConnectionDiscovered within + // a single millisecond). let lastSeen: VestigeEvent | null = null; eventFeed.subscribe(events => { if (events.length === 0) return; - const latest = events[0]; - if (latest === lastSeen) return; - lastSeen = latest; - const translated = translate(latest); - if (translated) push(translated); + const fresh: VestigeEvent[] = []; + for (const e of events) { + if (e === lastSeen) break; + fresh.push(e); + } + if (fresh.length === 0) return; + lastSeen = events[0]; + // Process oldest-first so narrative ordering is preserved. + for (let i = fresh.length - 1; i >= 0; i--) { + const translated = translate(fresh[i]); + if (translated) push(translated); + } }); return { subscribe, dismiss, clear, + pauseDwell, + resumeDwell, /** Manually fire a toast (test mode / demo button). */ push, };