From f40aa2e086c3b41f39bdffad32bd5d9f84a7e5e9 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 20 Apr 2026 12:47:37 -0500 Subject: [PATCH] feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a glowing orb materialises in the cosmic center (camera-relative z=-40), gestates for ~800ms growing from a tiny spark into a full orb, then arcs along a dynamic quadratic Bezier curve to the live position of the real node, and on arrival hands off to the existing RainbowBurst + Shockwave + RippleWave cascade. The target position is re-resolved every frame so the force simulation can move the destination during flight without the orb losing its mark. **New primitive — EffectManager.createBirthOrb()** (effects.ts): Accepts a camera, a color, a live target-position getter, and an arrival callback. Owns a sprite pair (outer halo + inner bright core), both depthTest:false with renderOrder 999/1000 so the orb is always visible through the starfield and the graph. - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints from neutral to event color as the ritual charges. - Flight phase: QuadraticBezierCurve3 with control point at midpoint raised on Y by 30 + 15% of orb-to-target distance (shooting-star arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target. - Arrival: fires onArrive callback once, then fades out over 8 frames while expanding slightly (energy dispersal). - Caller's onArrive triggers the burst cascade at arrivePos (NOT the original spawnPos — the force sim may have moved the target during the ritual, so we re-read nodeManager.positions on arrival). - Dispose path integrated with existing EffectManager.dispose(). **Event pipeline fix — Graph3D.processEvents()**: Previously tracked `processedEventCount` assuming APPEND order, but websocket.ts PREPENDS new events (index 0) and caps the array at MAX_EVENTS. Result: only the first MemoryCreated event after page load fired correctly; subsequent ones reprocessed the oldest entry. Fixed to walk from index 0 until hitting the last-processed event by reference identity — correct regardless of array direction or eviction pressure. Events are then processed oldest-first so causes precede effects. Found while wiring the v2.3 demo button; would have manifested as "first orb only" in production. **Demo trigger** (Settings -> Birth Ritual Preview): Button that calls websocket.injectEvent() with a synthetic MemoryCreated event, cycling through node types (fact / concept / pattern / decision / person / place) to showcase the type-color mapping. Downstream consumers can't distinguish synthetic from real, so this drives the full ritual end-to-end. Intended for demo clip recording for the Wednesday launch. **Test coverage:** - events.test.ts now tests the v2.3 birth ritual path: spawns 2+ sprites in the scene immediately, and fires the full arrival cascade after driving the effects.update() loop past the ritual duration. - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion, Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture, and Object3D.quaternion/renderOrder so production code runs unaltered in tests. Build + typecheck: - npm run check: 0 errors, 0 warnings across 583 files - npm test: 251/251 pass (net +0 from v2.2) - npm run build: clean adapter-static output The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs server-side event plumbing and is deferred. Ship this as the Wednesday visual mic-drop. --- .../src/lib/components/Graph3D.svelte | 31 ++- .../src/lib/graph/__tests__/events.test.ts | 33 ++- .../src/lib/graph/__tests__/three-mock.ts | 58 +++++ apps/dashboard/src/lib/graph/effects.ts | 204 ++++++++++++++++++ apps/dashboard/src/lib/graph/events.ts | 40 +++- apps/dashboard/src/lib/graph/nodes.ts | 2 +- apps/dashboard/src/lib/stores/websocket.ts | 17 +- .../src/routes/(app)/settings/+page.svelte | 37 +++- 8 files changed, 393 insertions(+), 29 deletions(-) diff --git a/apps/dashboard/src/lib/components/Graph3D.svelte b/apps/dashboard/src/lib/components/Graph3D.svelte index a9960a2..3f88868 100644 --- a/apps/dashboard/src/lib/components/Graph3D.svelte +++ b/apps/dashboard/src/lib/components/Graph3D.svelte @@ -59,8 +59,13 @@ let nebulaMaterial: THREE.ShaderMaterial; let postStack: PostProcessingStack; - // Event tracking - let processedEventCount = 0; + // Event tracking — we track the last-processed event by reference identity + // rather than by count, because the WebSocket store PREPENDS new events + // at index 0 and CAPS the array at MAX_EVENTS, so a numeric high-water + // mark would drift out of alignment (and did for ~3 versions — v2.3 + // demo uncovered this while trying to fire multiple MemoryCreated events + // in sequence). + let lastProcessedEvent: VestigeEvent | null = null; // Internal tracking: initial nodes + live-added nodes let allNodes: GraphNode[] = []; @@ -157,10 +162,19 @@ } function processEvents() { - if (!events || events.length <= processedEventCount) return; + if (!events || events.length === 0) return; - const newEvents = events.slice(processedEventCount); - processedEventCount = events.length; + // Walk the feed from newest (index 0) backward until we hit the last + // event we already processed. Everything between is fresh. This is + // robust against both (a) prepend ordering and (b) the MAX_EVENTS cap + // dropping old entries off the tail. + const fresh: VestigeEvent[] = []; + for (const e of events) { + if (e === lastProcessedEvent) break; + fresh.push(e); + } + if (fresh.length === 0) return; + lastProcessedEvent = events[0]; const mutationCtx: GraphMutationContext = { effects, @@ -180,8 +194,11 @@ }, }; - for (const event of newEvents) { - mapEventToEffects(event, mutationCtx, allNodes); + // Process oldest-first so cause precedes effect (e.g. MemoryCreated + // fires before a ConnectionDiscovered that references the new node). + // `fresh` is newest-first from the walk above, so iterate reversed. + for (let i = fresh.length - 1; i >= 0; i--) { + mapEventToEffects(fresh[i], mutationCtx, allNodes); } } diff --git a/apps/dashboard/src/lib/graph/__tests__/events.test.ts b/apps/dashboard/src/lib/graph/__tests__/events.test.ts index 5ffe198..8746597 100644 --- a/apps/dashboard/src/lib/graph/__tests__/events.test.ts +++ b/apps/dashboard/src/lib/graph/__tests__/events.test.ts @@ -155,7 +155,7 @@ describe('Event-to-Mutation Pipeline', () => { expect(distToN1).toBeLessThan(20); }); - it('triggers rainbow burst effect', () => { + it('spawns a v2.3 birth orb in the scene', () => { const childrenBefore = scene.children.length; mapEventToEffects( @@ -168,16 +168,19 @@ describe('Event-to-Mutation Pipeline', () => { allNodes ); - // Scene should have new particles (rainbow burst + shockwave + possibly more) - expect(scene.children.length).toBeGreaterThan(childrenBefore); + // Birth orb adds a halo sprite + bright core sprite to the scene + // immediately. The arrival-cascade effects (rainbow burst, shockwaves, + // ripple wave) are deferred to the orb's onArrive callback — covered + // by the "fires arrival cascade after ritual" test below. + expect(scene.children.length).toBeGreaterThanOrEqual(childrenBefore + 2); }); - it('triggers double shockwave (second delayed)', () => { + it('fires the arrival cascade after the birth ritual completes', () => { vi.useFakeTimers(); mapEventToEffects( makeEvent('MemoryCreated', { - id: 'double-shock', + id: 'cascade-check', content: 'test', node_type: 'fact', }), @@ -185,13 +188,23 @@ describe('Event-to-Mutation Pipeline', () => { allNodes ); - const initialChildren = scene.children.length; + const afterSpawn = scene.children.length; - // Advance past the setTimeout - vi.advanceTimersByTime(200); + // Drive the effects update loop past the full ritual duration + // (gestation 48 + flight 90 = 138 frames). Each tick is one frame; + // we run 150 to give onArrive room to fire. + for (let i = 0; i < 150; i++) { + effects.update(nodeManager.meshMap, camera, nodeManager.positions); + } - // Second shockwave should have been added - expect(scene.children.length).toBeGreaterThan(initialChildren); + // Advance the setTimeout that schedules the delayed second shockwave. + vi.advanceTimersByTime(250); + + // The arrival cascade should have added a rainbow burst, shockwave, + // ripple wave, and delayed second shockwave to the scene. Even after + // the orb fades out and is removed, the burst particles persist long + // enough that children.length should exceed the post-spawn count. + expect(scene.children.length).toBeGreaterThan(afterSpawn); vi.useRealTimers(); }); diff --git a/apps/dashboard/src/lib/graph/__tests__/three-mock.ts b/apps/dashboard/src/lib/graph/__tests__/three-mock.ts index b665ed7..0853560 100644 --- a/apps/dashboard/src/lib/graph/__tests__/three-mock.ts +++ b/apps/dashboard/src/lib/graph/__tests__/three-mock.ts @@ -93,6 +93,52 @@ export class Vector3 { this.z = s; return this; } + + addVectors(a: Vector3, b: Vector3) { + this.x = a.x + b.x; + this.y = a.y + b.y; + this.z = a.z + b.z; + return this; + } + + applyQuaternion(_q: Quaternion) { + // Mock: identity transform. Tests don't care about actual + // camera-relative positioning; production uses real THREE math. + return this; + } +} + +export class Quaternion { + x = 0; + y = 0; + z = 0; + w = 1; +} + +export class QuadraticBezierCurve3 { + v0: Vector3; + v1: Vector3; + v2: Vector3; + constructor(v0: Vector3, v1: Vector3, v2: Vector3) { + this.v0 = v0; + this.v1 = v1; + this.v2 = v2; + } + getPoint(t: number): Vector3 { + // Standard quadratic Bezier evaluation, faithful enough for tests + // that only care that points land on the curve. + const one = 1 - t; + return new Vector3( + one * one * this.v0.x + 2 * one * t * this.v1.x + t * t * this.v2.x, + one * one * this.v0.y + 2 * one * t * this.v1.y + t * t * this.v2.y, + one * one * this.v0.z + 2 * one * t * this.v1.z + t * t * this.v2.z + ); + } +} + +export class Texture { + needsUpdate = false; + dispose() {} } export class Vector2 { @@ -157,6 +203,13 @@ export class Color { offsetHSL(_h: number, _s: number, _l: number) { return this; } + + multiplyScalar(s: number) { + this.r *= s; + this.g *= s; + this.b *= s; + return this; + } } export class BufferAttribute { @@ -329,6 +382,8 @@ export class SpriteMaterial extends BaseMaterial { export class Object3D { position = new Vector3(); scale = new Vector3(1, 1, 1); + quaternion = new Quaternion(); + renderOrder = 0; userData: Record = {}; children: Object3D[] = []; parent: Object3D | null = null; @@ -428,6 +483,9 @@ export function installThreeMock() { Vector3, Vector2, Color, + Quaternion, + QuadraticBezierCurve3, + Texture, BufferAttribute, BufferGeometry, SphereGeometry, diff --git a/apps/dashboard/src/lib/graph/effects.ts b/apps/dashboard/src/lib/graph/effects.ts index 1402476..df4c21f 100644 --- a/apps/dashboard/src/lib/graph/effects.ts +++ b/apps/dashboard/src/lib/graph/effects.ts @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { getGlowTexture } from './nodes'; export interface PulseEffect { nodeId: string; @@ -49,6 +50,28 @@ interface ConnectionFlash { intensity: number; } +// v2.3 Memory Birth Ritual. The orb gestates at a camera-relative "cosmic +// center" point for `gestationFrames`, then flies along a dynamic quadratic +// Bezier curve to the live position of its target node for `flightFrames`, +// then calls `onArrive` and disposes itself. The target position is +// resolved via `getTargetPos` on every frame so the force simulation can +// move the node during the flight and the orb stays glued to it. +interface BirthOrb { + sprite: THREE.Sprite; + core: THREE.Sprite; + startPos: THREE.Vector3; + getTargetPos: () => THREE.Vector3 | undefined; + color: THREE.Color; + age: number; + gestationFrames: number; + flightFrames: number; + arriveFired: boolean; + onArrive: () => void; + /** 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; +} + export class EffectManager { pulseEffects: PulseEffect[] = []; private spawnBursts: SpawnBurst[] = []; @@ -57,6 +80,7 @@ export class EffectManager { private implosions: ImplosionEffect[] = []; private shockwaves: Shockwave[] = []; private connectionFlashes: ConnectionFlash[] = []; + private birthOrbs: BirthOrb[] = []; private scene: THREE.Scene; constructor(scene: THREE.Scene) { @@ -231,6 +255,88 @@ export class EffectManager { this.connectionFlashes.push({ line, intensity: 1.0 }); } + /** + * v2.3 Memory Birth Ritual. Spawn a glowing orb at a point in front of the + * camera ("cosmic center"), gestate for ~800ms, then arc along a quadratic + * Bezier curve to the live position of the target node, which is resolved + * on every frame via `getTargetPos`. On arrival, `onArrive` fires — caller + * is responsible for adding the real node to the graph and triggering + * arrival-time bursts. + * + * The target getter can return undefined if the node has been removed + * mid-flight; the orb then flies to the last known target position so the + * burst still fires somewhere coherent rather than snapping to origin. + */ + createBirthOrb( + camera: THREE.Camera, + color: THREE.Color, + getTargetPos: () => THREE.Vector3 | undefined, + onArrive: () => void, + opts: { gestationFrames?: number; flightFrames?: number; distanceFromCamera?: number } = {} + ) { + const gestationFrames = opts.gestationFrames ?? 48; // ~800ms + const flightFrames = opts.flightFrames ?? 90; // ~1500ms + const distanceFromCamera = opts.distanceFromCamera ?? 40; + + // Place the orb slightly in front of the camera, in view-space, + // projected back into world coordinates. This way the orb always + // appears "right in front of the user's face" regardless of where + // the camera has been orbited to. + const startPos = new THREE.Vector3(0, 0, -distanceFromCamera) + .applyQuaternion(camera.quaternion) + .add(camera.position); + + // Outer glow halo + const haloMat = new THREE.SpriteMaterial({ + map: getGlowTexture(), + color: color.clone(), + transparent: true, + opacity: 0.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + depthTest: false, // always visible, even through other nodes + }); + const sprite = new THREE.Sprite(haloMat); + sprite.position.copy(startPos); + sprite.scale.set(0.5, 0.5, 1); + sprite.renderOrder = 999; + + // Inner bright core — stays hot white during gestation, tints at launch + const coreMat = new THREE.SpriteMaterial({ + map: getGlowTexture(), + color: new THREE.Color(0xffffff), + transparent: true, + opacity: 0.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + depthTest: false, + }); + const core = new THREE.Sprite(coreMat); + core.position.copy(startPos); + core.scale.set(0.2, 0.2, 1); + core.renderOrder = 1000; + + this.scene.add(sprite); + this.scene.add(core); + + // Snapshot the current target so we have a fallback. + const initialTarget = getTargetPos()?.clone() ?? startPos.clone(); + + this.birthOrbs.push({ + sprite, + core, + startPos, + getTargetPos, + color: color.clone(), + age: 0, + gestationFrames, + flightFrames, + arriveFired: false, + onArrive, + lastTargetPos: initialTarget, + }); + } + update( nodeMeshMap: Map, camera: THREE.Camera, @@ -431,6 +537,97 @@ export class EffectManager { } (flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity; } + + // v2.3 Birth orbs — gestate at cosmic center, then arc to live node + // position along a quadratic Bezier curve. Target position is + // re-resolved every frame so the force simulation can move the + // destination during flight without the orb losing its mark. + for (let i = this.birthOrbs.length - 1; i >= 0; i--) { + const orb = this.birthOrbs[i]; + 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; + + if (orb.age <= orb.gestationFrames) { + // Gestation phase — pulse brighter + grow from a tiny spark + // into a full orb. Sits still at the cosmic center. + const t = orb.age / orb.gestationFrames; + const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic + const pulse = 0.85 + Math.sin(orb.age * 0.35) * 0.15; + const haloScale = 0.5 + ease * 4.5 * pulse; + const coreScale = 0.2 + ease * 1.8 * pulse; + orb.sprite.scale.set(haloScale, haloScale, 1); + orb.core.scale.set(coreScale, coreScale, 1); + haloMat.opacity = ease * 0.95; + coreMat.opacity = ease; + // Subtle warm-up — core white, halo tints toward the event + // color as gestation completes. + haloMat.color.copy(orb.color).multiplyScalar(0.7 + ease * 0.3); + 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. + 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 curve = new THREE.QuadraticBezierCurve3(orb.startPos, control, target); + const p = curve.getPoint(ease); + orb.sprite.position.copy(p); + orb.core.position.copy(p); + + // Trail effect — shrink + brighten as it approaches target + const flightProgress = ease; + const shrink = 1 - flightProgress * 0.35; + orb.sprite.scale.setScalar(5 * shrink); + orb.core.scale.setScalar(2 * shrink); + haloMat.opacity = 0.95; + coreMat.opacity = 1.0; + // Shift halo fully to the event color during flight + haloMat.color.copy(orb.color); + } else if (!orb.arriveFired) { + // Docking — fire the arrival callback once. Let the caller + // trigger burst/ripple effects at the exact target point. + orb.arriveFired = true; + try { + orb.onArrive(); + } catch (e) { + // Effects must never take down the render loop. + // eslint-disable-next-line no-console + console.warn('[birth-orb] onArrive threw', e); + } + // Fade the orb out over a few more frames instead of popping. + } else { + // Post-arrival fade (8 frames ≈ 130ms) + const fadeAge = orb.age - totalFrames; + const fade = Math.max(0, 1 - fadeAge / 8); + haloMat.opacity = 0.95 * fade; + coreMat.opacity = 1.0 * fade; + orb.sprite.scale.setScalar(5 * (1 + (1 - fade) * 2)); + if (fade <= 0) { + this.scene.remove(orb.sprite); + this.scene.remove(orb.core); + haloMat.dispose(); + coreMat.dispose(); + this.birthOrbs.splice(i, 1); + } + } + } } dispose() { @@ -464,6 +661,12 @@ export class EffectManager { flash.line.geometry.dispose(); (flash.line.material as THREE.Material).dispose(); } + for (const orb of this.birthOrbs) { + this.scene.remove(orb.sprite); + this.scene.remove(orb.core); + (orb.sprite.material as THREE.Material).dispose(); + (orb.core.material as THREE.Material).dispose(); + } this.pulseEffects = []; this.spawnBursts = []; this.rainbowBursts = []; @@ -471,5 +674,6 @@ export class EffectManager { this.implosions = []; this.shockwaves = []; this.connectionFlashes = []; + this.birthOrbs = []; } } diff --git a/apps/dashboard/src/lib/graph/events.ts b/apps/dashboard/src/lib/graph/events.ts index b13ce22..2a992dd 100644 --- a/apps/dashboard/src/lib/graph/events.ts +++ b/apps/dashboard/src/lib/graph/events.ts @@ -125,7 +125,11 @@ export function mapEventToEffects( // Find spawn position near related nodes const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions); - // Add to all managers + // 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); forceSim.addNode(data.id, pos); @@ -133,17 +137,35 @@ export function mapEventToEffects( liveSpawnedNodes.push(data.id); evictOldestLiveNode(ctx, allNodes); - // Spectacular effects: rainbow burst + double shockwave + ripple wave + // 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. const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1'); - effects.createRainbowBurst(spawnPos, color); - effects.createShockwave(spawnPos, color, camera); - // Second shockwave, hue-shifted, delayed via smaller initial scale const hueShifted = color.clone(); hueShifted.offsetHSL(0.15, 0, 0); - setTimeout(() => { - effects.createShockwave(spawnPos, hueShifted, camera); - }, 166); // ~10 frames at 60fps - effects.createRippleWave(spawnPos); + + effects.createBirthOrb( + camera, + color, + // Re-resolve the live target position every frame — the node + // is being pushed around by the force sim during flight. + () => nodeManager.positions.get(newNode.id), + () => { + // Dock. Fire the arrival cascade at the node's current + // position (not the original spawnPos — it has moved). + const arrivePos = nodeManager.positions.get(newNode.id) ?? spawnPos; + effects.createRainbowBurst(arrivePos, color); + effects.createShockwave(arrivePos, color, 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); + } + ); onMutation({ type: 'nodeAdded', node: newNode }); break; diff --git a/apps/dashboard/src/lib/graph/nodes.ts b/apps/dashboard/src/lib/graph/nodes.ts index 0fef301..5a1a9a7 100644 --- a/apps/dashboard/src/lib/graph/nodes.ts +++ b/apps/dashboard/src/lib/graph/nodes.ts @@ -67,7 +67,7 @@ export function getNodeColor(node: GraphNode, mode: ColorMode): string { // hard-edged "glowing cubes" artefact reported in issue #31. Using a // soft radial gradient gives a real round halo and lets bloom do its job. let sharedGlowTexture: THREE.Texture | null = null; -function getGlowTexture(): THREE.Texture { +export function getGlowTexture(): THREE.Texture { if (sharedGlowTexture) return sharedGlowTexture; const size = 128; const canvas = document.createElement('canvas'); diff --git a/apps/dashboard/src/lib/stores/websocket.ts b/apps/dashboard/src/lib/stores/websocket.ts index f96d229..12f0a5d 100644 --- a/apps/dashboard/src/lib/stores/websocket.ts +++ b/apps/dashboard/src/lib/stores/websocket.ts @@ -81,11 +81,26 @@ function createWebSocketStore() { update(s => ({ ...s, events: [] })); } + /** + * Inject a synthetic event into the feed as if it had arrived over the + * WebSocket. Used by the dev-mode "Preview Birth Ritual" button on the + * Settings page to let Sam trigger a demo of the v2.3 Memory Birth + * Ritual without ingesting a real memory. Downstream consumers — + * InsightToast, Graph3D — cannot distinguish synthetic from real. + */ + function injectEvent(event: VestigeEvent) { + update(s => { + const events = [event, ...s.events].slice(0, MAX_EVENTS); + return { ...s, events }; + }); + } + return { subscribe, connect, disconnect, - clearEvents + clearEvents, + injectEvent }; } diff --git a/apps/dashboard/src/routes/(app)/settings/+page.svelte b/apps/dashboard/src/routes/(app)/settings/+page.svelte index 9257ba2..f7a82eb 100644 --- a/apps/dashboard/src/routes/(app)/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/settings/+page.svelte @@ -1,9 +1,30 @@