// GhostLog event bus — mirrors the tiny typed emitter pattern in // lib/haunting.ts (HauntBus). It exists because is mounted once // at the app-shell level (visible on every screen) while the live WS session // lives inside , which is only mounted within the séance // route/page. React context can't cross that boundary (a shell-level // sibling can't read a provider nested under a route), so SeanceProvider // publishes the frames GhostLog cares about onto this bus instead — GhostLog // subscribes for the lifetime of the app and never needs to sit inside the // séance's own component tree. export type GhostLogEventMap = { /** A new entity is present (or the session cleared it — entity: null). */ entity: { name: string | null } /** One `tell` frame arrived from the server. */ tell: { text: string } /** A `ritual_complete` frame arrived. */ ritual_complete: { success: boolean; revealed: { alignment: number } | null } /** The active séance session ended (page left / socket torn down) — * GhostLog should fall back to its idle state. */ session_end: Record } type GhostLogHandler = (detail: T) => void /** Tiny typed emitter — one bad listener never silences the others. */ export class GhostLogBus { private handlers = new Map>>() on( type: K, handler: GhostLogHandler, ): () => void { let set = this.handlers.get(type) if (!set) { set = new Set() this.handlers.set(type, set) } const h = handler as GhostLogHandler set.add(h) return () => { set.delete(h) } } emit(type: K, detail: GhostLogEventMap[K]): void { const set = this.handlers.get(type) if (!set) return for (const h of [...set]) (h as GhostLogHandler)(detail) } } /** The shared bus the app-shell GhostLog and the séance provider both use. */ export const ghostLogBus = new GhostLogBus()