diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a33a6a8..5ff5c54 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,6 +4,7 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './state/auth'
import { HauntingLayer } from './components/HauntingLayer'
+import { GhostLog } from './components/GhostLog'
import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage'
import { EnterPage } from './pages/EnterPage'
@@ -20,6 +21,7 @@ function App() {
+
} />
} />
diff --git a/frontend/src/components/GhostLog.css b/frontend/src/components/GhostLog.css
new file mode 100644
index 0000000..b2f165a
--- /dev/null
+++ b/frontend/src/components/GhostLog.css
@@ -0,0 +1,258 @@
+/* GhostLog — app-shell HUD, mounted once alongside HauntingLayer so it sits
+ above every page. "Hacker witch": Transcript.tsx's terminal vocabulary
+ (monospace log rows, phosphor glow, flicker) fused with occult framing
+ (sigil corners, rune bullets, pentagram divider, crescent evil-meter).
+ Palette borrowed from App.css's --qm-* tokens / SeancePage.css — no new
+ colors introduced. */
+
+.ghost-log {
+ position: fixed;
+ left: 14px;
+ bottom: 14px;
+ z-index: 500; /* above page content, below the global CRT glass (9999) and haunting layer (9998) */
+ width: 300px;
+ max-width: calc(100vw - 28px);
+ max-height: 46vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--qm-panel, rgba(16, 16, 26, 0.85));
+ border: 1px solid var(--qm-border, rgba(124, 255, 178, 0.18));
+ border-radius: 6px;
+ box-shadow:
+ 0 6px 28px rgba(0, 0, 0, 0.55),
+ 0 0 18px rgba(178, 107, 255, 0.08),
+ inset 0 0 24px rgba(124, 255, 178, 0.03);
+ backdrop-filter: blur(4px);
+ font-family: var(--qm-font-ui, 'IBM Plex Mono', monospace);
+ overflow: hidden;
+ pointer-events: auto;
+}
+
+/* candle-flicker glow instead of a flat terminal green */
+.ghost-log--active {
+ animation: ghostLogCandle 5.5s ease-in-out infinite;
+}
+
+.ghost-log-corner {
+ position: absolute;
+ top: 4px;
+ font-size: 0.7rem;
+ color: rgba(178, 107, 255, 0.55);
+ text-shadow: 0 0 8px rgba(178, 107, 255, 0.5);
+ pointer-events: none;
+}
+
+.ghost-log-corner--tl {
+ left: 6px;
+}
+
+.ghost-log-corner--tr {
+ right: 6px;
+}
+
+.ghost-log-header {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.55rem 1rem 0.35rem;
+}
+
+.ghost-log-glyph {
+ color: var(--qm-violet, #b26bff);
+ text-shadow: 0 0 10px rgba(178, 107, 255, 0.7);
+ font-size: 0.95rem;
+}
+
+.ghost-log-title {
+ font-family: var(--qm-font-display, 'Cinzel', serif);
+ font-weight: 700;
+ font-size: 0.72rem;
+ letter-spacing: 0.28em;
+ color: var(--qm-green, #7cffb2);
+ text-shadow: 0 0 10px rgba(124, 255, 178, 0.4);
+}
+
+.ghost-log-divider {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.1rem 0 0.3rem;
+ color: rgba(178, 107, 255, 0.4);
+ font-size: 0.6rem;
+ letter-spacing: 0.3em;
+}
+
+.ghost-log-divider-sigil {
+ color: var(--qm-blood, #ff3b5c);
+ text-shadow: 0 0 8px rgba(255, 59, 92, 0.5);
+ font-size: 0.75rem;
+}
+
+.ghost-log-status {
+ margin: 0;
+ padding: 0.3rem 1rem 0.8rem;
+ font-size: 0.74rem;
+ font-style: italic;
+ letter-spacing: 0.04em;
+ color: rgba(211, 228, 220, 0.55);
+}
+
+.ghost-log-status.dim {
+ opacity: 0.6;
+}
+
+.ghost-log-body {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 0.15rem 1rem 0.8rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.32rem;
+ font-size: 0.74rem;
+ line-height: 1.4;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(124, 255, 178, 0.3) transparent;
+}
+
+.ghost-log-body::-webkit-scrollbar {
+ width: 5px;
+}
+
+.ghost-log-body::-webkit-scrollbar-thumb {
+ background: rgba(124, 255, 178, 0.25);
+ border-radius: 3px;
+}
+
+.ghost-log-line {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+}
+
+.ghost-log-rune {
+ flex: 0 0 auto;
+ color: var(--qm-violet, #b26bff);
+ text-shadow: 0 0 6px rgba(178, 107, 255, 0.6);
+ font-size: 0.72rem;
+}
+
+.ghost-log-line--system .ghost-log-rune {
+ color: var(--qm-green, #7cffb2);
+ text-shadow: 0 0 6px rgba(124, 255, 178, 0.6);
+}
+
+.ghost-log-text {
+ color: rgba(211, 228, 220, 0.9);
+}
+
+.ghost-log-entity-name {
+ font-family: var(--qm-font-display, 'Cinzel', serif);
+ letter-spacing: 0.08em;
+ color: var(--qm-green, #7cffb2);
+}
+
+/* ---------- evil meter gauge ---------- */
+
+.evil-meter {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0 0.6rem;
+}
+
+.evil-meter-svg {
+ width: 100%;
+ height: auto;
+ overflow: visible;
+}
+
+.evil-meter-track {
+ fill: none;
+ stroke: rgba(124, 255, 178, 0.14);
+ stroke-width: 12;
+ stroke-linecap: round;
+}
+
+.evil-meter-band {
+ fill: rgba(178, 107, 255, 0.4);
+ filter: drop-shadow(0 0 6px rgba(178, 107, 255, 0.55));
+ transition: d 200ms ease;
+}
+
+.evil-meter-band--certain {
+ fill: rgba(124, 255, 178, 0.5);
+ filter: drop-shadow(0 0 10px rgba(124, 255, 178, 0.7));
+}
+
+/* CRT-scanline texture cut through the belief band — the gauge belongs to
+ both worlds at once: occult crescent shape, hacker scanline fill. */
+.evil-meter-scanline {
+ stroke: rgba(7, 7, 13, 0.55);
+ stroke-width: 1.4;
+}
+
+.evil-meter-band-texture {
+ opacity: 0.6;
+ mix-blend-mode: multiply;
+ pointer-events: none;
+}
+
+.evil-meter-needle {
+ stroke: #d9f5e4;
+ stroke-width: 2;
+ stroke-linecap: round;
+ filter: drop-shadow(0 0 4px rgba(217, 245, 228, 0.8));
+}
+
+.evil-meter-hub {
+ fill: #d9f5e4;
+}
+
+.evil-meter--certain .evil-meter-needle {
+ stroke: var(--qm-green, #7cffb2);
+}
+
+.evil-meter-readout {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-top: -0.35rem;
+ font-size: 0.66rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.evil-meter-glyph {
+ font-size: 0.7rem;
+ opacity: 0.65;
+}
+
+.evil-meter-glyph--malevolent {
+ color: var(--qm-blood, #ff3b5c);
+ text-shadow: 0 0 6px rgba(255, 59, 92, 0.5);
+}
+
+.evil-meter-glyph--benevolent {
+ color: var(--qm-green, #7cffb2);
+ text-shadow: 0 0 6px rgba(124, 255, 178, 0.5);
+}
+
+.evil-meter-label {
+ color: rgba(211, 228, 220, 0.85);
+ font-style: italic;
+}
+
+@keyframes ghostLogCandle {
+ 0%, 100% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 18px rgba(178, 107, 255, 0.08), inset 0 0 24px rgba(124, 255, 178, 0.03); }
+ 35% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 24px rgba(178, 107, 255, 0.16), inset 0 0 28px rgba(124, 255, 178, 0.05); }
+ 60% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 14px rgba(178, 107, 255, 0.06), inset 0 0 20px rgba(124, 255, 178, 0.02); }
+ 80% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 22px rgba(178, 107, 255, 0.14), inset 0 0 26px rgba(124, 255, 178, 0.04); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ghost-log--active {
+ animation: none;
+ }
+}
diff --git a/frontend/src/components/GhostLog.test.tsx b/frontend/src/components/GhostLog.test.tsx
new file mode 100644
index 0000000..d441d30
--- /dev/null
+++ b/frontend/src/components/GhostLog.test.tsx
@@ -0,0 +1,139 @@
+import { act, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import '../i18n'
+import { GhostLog } from './GhostLog'
+import { ghostLogBus } from '../lib/ghostLogBus'
+import type { GhostLogEventMap } from '../lib/ghostLogBus'
+
+// GhostLog subscribes to the shared, module-level ghostLogBus (it's mounted
+// at the app-shell level with no props — see the component's header
+// comment for why). Tests drive it the same way does in
+// production: by emitting frames onto the bus and letting React flush.
+function emit(type: K, detail: GhostLogEventMap[K]) {
+ act(() => {
+ ghostLogBus.emit(type, detail)
+ })
+}
+
+describe('GhostLog — idle state', () => {
+ afterEach(() => {
+ emit('session_end', {})
+ })
+
+ it('shows ambient status text with no active entity/session', () => {
+ render()
+ expect(
+ screen.getByText('the veil is quiet — no presence detected'),
+ ).toBeTruthy()
+ })
+
+ it('renders no evil-meter gauge while idle', () => {
+ render()
+ expect(screen.queryByTestId('evil-meter')).toBeNull()
+ })
+
+ it('carries the idle class on the root element', () => {
+ const { container } = render()
+ expect(container.querySelector('.ghost-log--idle')).not.toBeNull()
+ expect(container.querySelector('.ghost-log--active')).toBeNull()
+ })
+})
+
+describe('GhostLog — active session with tells', () => {
+ afterEach(() => {
+ emit('session_end', {})
+ })
+
+ it('switches out of idle once an entity frame arrives', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+
+ expect(screen.queryByText('the veil is quiet — no presence detected')).toBeNull()
+ expect(screen.getByText('The Hollow Choir')).toBeTruthy()
+ })
+
+ it('shows a waiting line before any tells arrive', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ expect(screen.getByText('listening for tells…')).toBeTruthy()
+ })
+
+ it('streams tell frames in as log lines, most recent last (Transcript convention)', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ emit('tell', { text: 'the entity avoided a direct question' })
+ emit('tell', { text: 'a cold draft crossed the room' })
+
+ expect(screen.getByText('the entity avoided a direct question')).toBeTruthy()
+ expect(screen.getByText('a cold draft crossed the room')).toBeTruthy()
+
+ const lines = Array.from(document.querySelectorAll('.ghost-log-line .ghost-log-text')).map(
+ (el) => el.textContent,
+ )
+ // entity name row + two tell lines, tells in arrival order
+ expect(lines).toEqual([
+ 'The Hollow Choir',
+ 'the entity avoided a direct question',
+ 'a cold draft crossed the room',
+ ])
+ })
+
+ it('renders an evil-meter gauge once active, reflecting the tell history', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ emit('tell', { text: 'the entity avoided a direct question' })
+
+ const gauge = screen.getByTestId('evil-meter')
+ expect(gauge).toBeTruthy()
+ const meterEl = gauge.querySelector('[role="meter"]')
+ expect(meterEl).not.toBeNull()
+ expect(meterEl?.getAttribute('aria-valuemin')).toBe('0')
+ expect(meterEl?.getAttribute('aria-valuemax')).toBe('1')
+ // starts near the uncertain midpoint before any tells narrow it
+ expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeGreaterThanOrEqual(0)
+ expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(1)
+ })
+
+ it('snaps the gauge to certainty on a successful ritual_complete', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ emit('tell', { text: 'the entity avoided a direct question' })
+ emit('ritual_complete', { success: true, revealed: { alignment: 0.92 } })
+
+ const gauge = screen.getByTestId('evil-meter')
+ expect(gauge.className).toContain('evil-meter--certain')
+ const meterEl = gauge.querySelector('[role="meter"]')
+ expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeCloseTo(0.92, 3)
+ })
+
+ it('does not snap to certainty on a failed ritual_complete', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ emit('ritual_complete', { success: false, revealed: null })
+
+ const gauge = screen.getByTestId('evil-meter')
+ expect(gauge.className).not.toContain('evil-meter--certain')
+ })
+
+ it('resets back to idle on session_end (leaving the séance)', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ emit('tell', { text: 'a cold draft crossed the room' })
+ emit('session_end', {})
+
+ expect(
+ screen.getByText('the veil is quiet — no presence detected'),
+ ).toBeTruthy()
+ expect(screen.queryByText('a cold draft crossed the room')).toBeNull()
+ })
+
+ it('starts a fresh log when a new entity frame supersedes the old one', () => {
+ render()
+ emit('entity', { name: 'The Hollow Choir' })
+ emit('tell', { text: 'a cold draft crossed the room' })
+ emit('entity', { name: 'The Weeping Usher' })
+
+ expect(screen.getByText('The Weeping Usher')).toBeTruthy()
+ expect(screen.queryByText('a cold draft crossed the room')).toBeNull()
+ })
+})
diff --git a/frontend/src/components/GhostLog.tsx b/frontend/src/components/GhostLog.tsx
new file mode 100644
index 0000000..9cc5788
--- /dev/null
+++ b/frontend/src/components/GhostLog.tsx
@@ -0,0 +1,254 @@
+// GhostLog — the app-shell HUD (mounted once in App.tsx, alongside
+// HauntingLayer, so it's visible on every screen, not just the séance
+// page). It has no props: it subscribes directly to `ghostLogBus`
+// (lib/ghostLogBus.ts), which publishes onto whenever a
+// live séance is running. That indirection exists because the provider is
+// only mounted inside the séance route — a shell-level sibling can't read
+// its context directly — so this component stays fully functional (idle
+// state) on every page with no active WS connection, and comes alive with
+// streamed `tell` lines once a séance actually starts.
+//
+// Visual language: "hacker witch" — Transcript.tsx's terminal/log
+// vocabulary (monospace rows, phosphor glow, flicker) fused with occult
+// framing (sigil corner ornaments, rune bullets instead of dashes, a
+// pentagram divider, a crescent-arc evil-meter gauge with a CRT scanline
+// texture standing in for a plain progress bar).
+
+import { useEffect, useId, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { ghostLogBus } from '../lib/ghostLogBus'
+import { computeEvilMeter, evilMeterLabel } from '../lib/evilMeter'
+import type { EvilMeterState, RitualReveal } from '../lib/evilMeter'
+import './GhostLog.css'
+
+type TellLine = { id: number; text: string; at: number }
+
+/** Bounded so a very long séance never grows the HUD's memory unbounded. */
+const TELL_CAP = 60
+
+let idSeq = 1
+const nextId = () => idSeq++
+
+function clamp01(n: number): number {
+ return Math.min(1, Math.max(0, n))
+}
+
+export function GhostLog() {
+ const { t } = useTranslation()
+ const [entityName, setEntityName] = useState(null)
+ const [tells, setTells] = useState([])
+ const [ritualReveal, setRitualReveal] = useState(null)
+ const bodyRef = useRef(null)
+
+ useEffect(() => {
+ // A fresh `entity` event means a new (or newly re-summoned) presence —
+ // start this HUD's log over rather than carrying the previous entity's
+ // tells/ritual reveal forward.
+ const offEntity = ghostLogBus.on('entity', ({ name }) => {
+ setEntityName(name)
+ setTells([])
+ setRitualReveal(null)
+ })
+ const offTell = ghostLogBus.on('tell', ({ text }) => {
+ setTells((prev) => {
+ const next = [...prev, { id: nextId(), text, at: Date.now() }]
+ return next.length > TELL_CAP ? next.slice(next.length - TELL_CAP) : next
+ })
+ })
+ const offRitual = ghostLogBus.on('ritual_complete', (detail) => {
+ setRitualReveal(detail)
+ })
+ const offEnd = ghostLogBus.on('session_end', () => {
+ setEntityName(null)
+ setTells([])
+ setRitualReveal(null)
+ })
+ return () => {
+ offEntity()
+ offTell()
+ offRitual()
+ offEnd()
+ }
+ }, [])
+
+ // Same scroll convention as Transcript.tsx: newest line appended at the
+ // bottom, view auto-scrolls down to follow it.
+ useEffect(() => {
+ const box = bodyRef.current
+ if (box) box.scrollTop = box.scrollHeight
+ }, [tells.length])
+
+ const meter = useMemo(
+ () => computeEvilMeter(tells, ritualReveal),
+ [tells, ritualReveal],
+ )
+ const active = entityName !== null
+
+ return (
+
+ )
+}
+
+export default GhostLog
+
+// ---- evil-meter gauge: a crescent arc, not a boring progress bar ----
+
+type GaugeProps = { meter: EvilMeterState; label: string }
+
+const CX = 60
+const CY = 56
+const R = 42
+const BAND_THICKNESS = 12
+
+function angleForValue(v: number): number {
+ // 0 (malevolent) -> 180deg (left); 1 (benevolent) -> 0deg (right); the
+ // sweep from 180 down to 0 traces the *top* semicircle — the crescent.
+ return 180 - clamp01(v) * 180
+}
+
+function polar(radius: number, angleDeg: number): { x: number; y: number } {
+ const rad = (angleDeg * Math.PI) / 180
+ return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) }
+}
+
+function EvilMeterGauge({ meter, label }: GaugeProps) {
+ const { t } = useTranslation()
+ const patternId = useId()
+ const { value, spread, certain } = meter
+
+ const outerR = R + BAND_THICKNESS / 2
+ const innerR = R - BAND_THICKNESS / 2
+ const lowV = clamp01(value - spread)
+ const highV = clamp01(value + spread)
+ const lowAngle = angleForValue(lowV) // larger angle (further left)
+ const highAngle = angleForValue(highV) // smaller angle (further right)
+ const largeArc = lowAngle - highAngle > 180 ? 1 : 0
+
+ const outerStart = polar(outerR, lowAngle)
+ const outerEnd = polar(outerR, highAngle)
+ const innerEnd = polar(innerR, highAngle)
+ const innerStart = polar(innerR, lowAngle)
+
+ const bandPath = [
+ `M ${outerStart.x} ${outerStart.y}`,
+ `A ${outerR} ${outerR} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`,
+ `L ${innerEnd.x} ${innerEnd.y}`,
+ `A ${innerR} ${innerR} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`,
+ 'Z',
+ ].join(' ')
+
+ const trackStart = polar(R, 180)
+ const trackEnd = polar(R, 0)
+ const trackPath = `M ${trackStart.x} ${trackStart.y} A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}`
+
+ const needle = polar(R + BAND_THICKNESS / 2 + 4, angleForValue(value))
+
+ return (
+
+
+
+
+ ⛧
+
+ {label}
+
+ ✧
+
+
+
+ )
+}
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 48cecee..eef61f5 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -351,5 +351,11 @@
"behindYou": "QUANTUM▓NCY — behind you",
"listening": "…listening…"
}
+ },
+ "ghostLog": {
+ "title": "GHOST LOG",
+ "idle": "the veil is quiet — no presence detected",
+ "waiting": "listening for tells…",
+ "meterLabel": "evil meter"
}
}
diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json
index 39b65ad..2fbc209 100644
--- a/frontend/src/i18n/es.json
+++ b/frontend/src/i18n/es.json
@@ -351,5 +351,11 @@
"behindYou": "QUANTUM▓NCY — detrás de ti",
"listening": "…escuchando…"
}
+ },
+ "ghostLog": {
+ "title": "REGISTRO FANTASMA",
+ "idle": "el velo está en silencio — no se detecta presencia",
+ "waiting": "escuchando indicios…",
+ "meterLabel": "medidor de maldad"
}
}
diff --git a/frontend/src/lib/evilMeter.test.ts b/frontend/src/lib/evilMeter.test.ts
new file mode 100644
index 0000000..fa44a79
--- /dev/null
+++ b/frontend/src/lib/evilMeter.test.ts
@@ -0,0 +1,169 @@
+import { describe, expect, it } from 'vitest'
+import {
+ EVIL_METER_INITIAL_SPREAD,
+ EVIL_METER_INITIAL_VALUE,
+ applyTell,
+ computeEvilMeter,
+ evilMeterLabel,
+} from './evilMeter'
+
+describe('computeEvilMeter — idle / empty history', () => {
+ it('starts at the wide, uncertain midpoint with no tells', () => {
+ const meter = computeEvilMeter([])
+ expect(meter.value).toBe(EVIL_METER_INITIAL_VALUE)
+ expect(meter.spread).toBe(EVIL_METER_INITIAL_SPREAD)
+ expect(meter.certain).toBe(false)
+ expect(meter.sampleCount).toBe(0)
+ })
+
+ it('is unaffected by a null/undefined ritualComplete', () => {
+ expect(computeEvilMeter([], null)).toEqual(computeEvilMeter([]))
+ expect(computeEvilMeter([], undefined)).toEqual(computeEvilMeter([]))
+ })
+})
+
+describe('computeEvilMeter — narrowing over a sequence of tells', () => {
+ const tells = [
+ { text: 'the entity avoided a direct question' },
+ { text: 'a cold draft crossed the room' },
+ { text: 'it answered warmly, without hesitation' },
+ { text: 'the static thickened around your voice' },
+ { text: 'it lingered on your name' },
+ ]
+
+ it('spread narrows monotonically (never widens) as tells accumulate', () => {
+ let spreads: number[] = []
+ for (let n = 0; n <= tells.length; n++) {
+ spreads.push(computeEvilMeter(tells.slice(0, n)).spread)
+ }
+ for (let i = 1; i < spreads.length; i++) {
+ expect(spreads[i]).toBeLessThanOrEqual(spreads[i - 1])
+ }
+ // strictly narrower after at least one tell
+ expect(spreads[1]).toBeLessThan(spreads[0])
+ })
+
+ it('spread approaches but never drops below the narrowing floor', () => {
+ const many = Array.from({ length: 200 }, (_, i) => ({ text: `tell number ${i}` }))
+ const meter = computeEvilMeter(many)
+ expect(meter.spread).toBeGreaterThan(0)
+ expect(meter.spread).toBeLessThan(EVIL_METER_INITIAL_SPREAD)
+ })
+
+ it('value stays within [0, 1] no matter how many tells arrive', () => {
+ const many = Array.from({ length: 500 }, (_, i) => ({ text: `wildly evil tell ${i}` }))
+ const meter = computeEvilMeter(many)
+ expect(meter.value).toBeGreaterThanOrEqual(0)
+ expect(meter.value).toBeLessThanOrEqual(1)
+ })
+
+ it('is a pure function: same tell sequence always yields the same belief', () => {
+ const a = computeEvilMeter(tells)
+ const b = computeEvilMeter(tells.map((t) => ({ text: t.text })))
+ expect(a).toEqual(b)
+ })
+
+ it('is sensitive to tell order (folds sequentially, not as a set)', () => {
+ const forward = computeEvilMeter(tells)
+ const reversed = computeEvilMeter([...tells].reverse())
+ expect(forward).not.toEqual(reversed)
+ })
+
+ it('never reports certain from tells alone, however many arrive', () => {
+ const many = Array.from({ length: 1000 }, (_, i) => ({ text: `tell ${i}` }))
+ expect(computeEvilMeter(many).certain).toBe(false)
+ })
+
+ it('sampleCount tracks the tell history length', () => {
+ expect(computeEvilMeter(tells).sampleCount).toBe(tells.length)
+ })
+})
+
+describe('applyTell — single-step narrowing primitive', () => {
+ it('always narrows spread by the fixed decay factor', () => {
+ const before = { value: 0.5, spread: 0.5 }
+ const after = applyTell(before, { text: 'anything' })
+ expect(after.spread).toBeCloseTo(before.spread * 0.82, 5)
+ })
+
+ it('is deterministic for a given text', () => {
+ const a = applyTell({ value: 0.5, spread: 0.5 }, { text: 'the shadows lengthened' })
+ const b = applyTell({ value: 0.5, spread: 0.5 }, { text: 'the shadows lengthened' })
+ expect(a).toEqual(b)
+ })
+
+ it('different text can nudge in different directions', () => {
+ const base = { value: 0.5, spread: 0.5 }
+ const results = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'].map(
+ (text) => applyTell(base, { text }).value,
+ )
+ const above = results.some((v) => v > 0.5)
+ const below = results.some((v) => v < 0.5)
+ expect(above && below).toBe(true)
+ })
+})
+
+describe('computeEvilMeter — ritual_complete override/snap', () => {
+ const tells = [
+ { text: 'the entity avoided a direct question' },
+ { text: 'a cold draft crossed the room' },
+ ]
+
+ it('snaps to the revealed alignment on a successful ritual, ignoring tell history', () => {
+ const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.9 } })
+ expect(meter.value).toBe(0.9)
+ expect(meter.spread).toBe(0)
+ expect(meter.certain).toBe(true)
+ })
+
+ it('snaps to a malevolent reading too (alignment near 0)', () => {
+ const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.05 } })
+ expect(meter.value).toBe(0.05)
+ expect(meter.certain).toBe(true)
+ })
+
+ it('clamps an out-of-range revealed alignment defensively', () => {
+ const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 1.4 } })
+ expect(meter.value).toBe(1)
+ })
+
+ it('does NOT snap on a failed ritual — falls back to the tell-driven belief', () => {
+ const withRitual = computeEvilMeter(tells, { success: false, revealed: null })
+ const withoutRitual = computeEvilMeter(tells)
+ expect(withRitual).toEqual(withoutRitual)
+ expect(withRitual.certain).toBe(false)
+ })
+
+ it('sampleCount still reflects the tell history even when snapped', () => {
+ const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.5 } })
+ expect(meter.sampleCount).toBe(tells.length)
+ })
+})
+
+describe('evilMeterLabel', () => {
+ it('reads "unknown" for the empty/idle state', () => {
+ expect(evilMeterLabel(computeEvilMeter([]))).toBe('unknown')
+ })
+
+ it('reads a confirmed label once certain', () => {
+ expect(
+ evilMeterLabel(computeEvilMeter([], { success: true, revealed: { alignment: 0.9 } })),
+ ).toBe('confirmed benevolent')
+ expect(
+ evilMeterLabel(computeEvilMeter([], { success: true, revealed: { alignment: 0.1 } })),
+ ).toBe('confirmed malevolent')
+ })
+
+ it('reads a leaning label once tells push the value past the threshold', () => {
+ // Hand-pick a value via the snap path (deterministic) to exercise the boundary text.
+ expect(
+ evilMeterLabel({ value: 0.7, spread: 0.2, certain: false, sampleCount: 3 }),
+ ).toBe('leaning benevolent')
+ expect(
+ evilMeterLabel({ value: 0.2, spread: 0.2, certain: false, sampleCount: 3 }),
+ ).toBe('leaning malevolent')
+ expect(
+ evilMeterLabel({ value: 0.5, spread: 0.2, certain: false, sampleCount: 3 }),
+ ).toBe('unreadable')
+ })
+})
diff --git a/frontend/src/lib/evilMeter.ts b/frontend/src/lib/evilMeter.ts
new file mode 100644
index 0000000..f9dfede
--- /dev/null
+++ b/frontend/src/lib/evilMeter.ts
@@ -0,0 +1,132 @@
+// The evil-meter: a pure function turning the accumulated `tell` history
+// into a belief about the entity's true nature along the malevolent <->
+// benevolent axis (mirrors the backend's hidden `traits.alignment`, where
+// 0 = malevolent/demon and 1 = benevolent spirit — see the Character Depth
+// spec's Contract section).
+//
+// Tells never carry the ground truth (that's the point — the backend keeps
+// `traits` hidden outside `ritual_complete`), so this reads only the tell
+// *text* itself. Each tell is opaque flavor text ("the entity avoided a
+// direct question"), so there is no shared vocabulary to parse reliably —
+// instead each tell's text is hashed into a deterministic pseudo-random
+// unit value. That value's distance from 0.5 becomes the nudge strength,
+// and its side of 0.5 becomes the nudge direction. This keeps the whole
+// thing a pure, deterministic function of the tell strings (same tells in
+// the same order always produce the same belief — directly unit-testable)
+// while still "reading between the lines" the way a real player would,
+// each new tell narrowing the uncertainty band a little further.
+//
+// A successful `ritual_complete` is ground truth: it snaps the meter to
+// definitive certainty at the entity's real `revealed.alignment`, ignoring
+// every tell that came before (and any that come after, until reset).
+
+export type Tell = { text: string }
+
+export type RitualReveal = {
+ success: boolean
+ revealed: { alignment: number } | null
+}
+
+export type EvilMeterState = {
+ /** 0 (malevolent) .. 1 (benevolent) — current best-guess mean. */
+ value: number
+ /** 0 (certain) .. 1 (maximally uncertain) — half-width of the belief band. */
+ spread: number
+ /** true once a successful ritual has snapped this to ground truth. */
+ certain: boolean
+ /** number of tells folded into this belief (0 when snapped by a ritual reset). */
+ sampleCount: number
+}
+
+export const EVIL_METER_INITIAL_VALUE = 0.5
+export const EVIL_METER_INITIAL_SPREAD = 0.5
+
+/** Spread never narrows past this from tells alone — only a ritual reveal reaches 0. */
+const MIN_SPREAD = 0.04
+/** Multiplicative narrowing applied to the spread per tell (geometric decay). */
+const SPREAD_DECAY = 0.82
+/** Nudge strength is scaled by the *current* spread, so early tells (wide
+ * spread) move the needle more than late ones (narrow spread) — this is
+ * what makes the belief "settle" rather than jitter forever. */
+const NUDGE_GAIN = 0.9
+
+function clamp01(n: number): number {
+ return Math.min(1, Math.max(0, n))
+}
+
+/** Deterministic 0..1 pseudo-random value from a string (FNV-1a-ish). */
+function hashUnit(s: string): number {
+ let h = 0x811c9dc5
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i)
+ h = Math.imul(h, 0x01000193)
+ }
+ return (h >>> 0) / 0x100000000
+}
+
+/**
+ * Folds one tell into a running (value, spread) belief. Exported separately
+ * from `computeEvilMeter` so the narrowing step itself is directly testable
+ * in isolation from the reduce-over-history driver.
+ */
+export function applyTell(
+ state: { value: number; spread: number },
+ tell: Tell,
+): { value: number; spread: number } {
+ const r = hashUnit(tell.text)
+ const direction = r >= 0.5 ? 1 : -1
+ const magnitude = Math.abs(r - 0.5) * 2 // 0..1
+ const step = direction * magnitude * state.spread * NUDGE_GAIN
+ return {
+ value: clamp01(state.value + step),
+ spread: Math.max(MIN_SPREAD, state.spread * SPREAD_DECAY),
+ }
+}
+
+/**
+ * Computes the current evil-meter belief from the full tell history.
+ *
+ * - No tells yet: wide/uncertain band centered on the midpoint.
+ * - Each subsequent tell narrows the spread and nudges the value.
+ * - A successful `ritualComplete` overrides everything: the meter snaps to
+ * the revealed alignment with zero spread ("certain"). A failed ritual
+ * (`success: false`, `revealed: null`) does not snap — the tell-driven
+ * belief carries on as if the ritual hadn't resolved anything.
+ */
+export function computeEvilMeter(
+ tells: readonly Tell[],
+ ritualComplete?: RitualReveal | null,
+): EvilMeterState {
+ if (ritualComplete && ritualComplete.success && ritualComplete.revealed) {
+ return {
+ value: clamp01(ritualComplete.revealed.alignment),
+ spread: 0,
+ certain: true,
+ sampleCount: tells.length,
+ }
+ }
+
+ let running = { value: EVIL_METER_INITIAL_VALUE, spread: EVIL_METER_INITIAL_SPREAD }
+ for (const tell of tells) {
+ running = applyTell(running, tell)
+ }
+
+ return {
+ value: running.value,
+ spread: running.spread,
+ certain: false,
+ sampleCount: tells.length,
+ }
+}
+
+/** Human-readable label for the current belief, for the HUD readout. */
+export function evilMeterLabel(state: EvilMeterState): string {
+ const { value, certain } = state
+ if (certain) {
+ return value >= 0.5 ? 'confirmed benevolent' : 'confirmed malevolent'
+ }
+ if (state.sampleCount === 0) return 'unknown'
+ if (value >= 0.66) return 'leaning benevolent'
+ if (value <= 0.34) return 'leaning malevolent'
+ return 'unreadable'
+}
diff --git a/frontend/src/lib/ghostLogBus.ts b/frontend/src/lib/ghostLogBus.ts
new file mode 100644
index 0000000..601d688
--- /dev/null
+++ b/frontend/src/lib/ghostLogBus.ts
@@ -0,0 +1,53 @@
+// 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()
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 25a2ef0..9cfae79 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -83,6 +83,16 @@ export type Telemetry = {
export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply'
+// Entity hidden traits, per the Character Depth spec's Contract section —
+// four floats each 0.0–1.0, rolled once at mint time. `alignment`: 0 =
+// malevolent/demon, 1 = benevolent spirit.
+export type EntityTraits = {
+ alignment: number
+ power: number
+ volatility: number
+ deceptiveness: number
+}
+
export type ServerFrame =
| { type: 'session'; id: string }
| { type: 'pong' }
@@ -98,6 +108,10 @@ export type ServerFrame =
| { type: 'reply_end'; id: string; text: string }
| { type: 'telemetry' } & Telemetry
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
+ // Character Depth / Ghost Log contract frames (backend built by a separate
+ // workstream — see docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
+ | { type: 'tell'; text: string }
+ | { type: 'ritual_complete'; success: boolean; revealed: EntityTraits | null }
// REST helpers
export type SortOrder = 'recent' | 'contacted'
diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx
index f078688..b01fe0b 100644
--- a/frontend/src/state/seance.tsx
+++ b/frontend/src/state/seance.tsx
@@ -15,6 +15,7 @@ import type { ReactNode } from 'react'
import { VeilSocket } from '../lib/ws'
import type { VeilConnectionState } from '../lib/ws'
import { SpiritAudioPlayer } from '../lib/audio'
+import { ghostLogBus } from '../lib/ghostLogBus'
import type {
Language,
Mode,
@@ -414,6 +415,18 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
// Auto-play spirit voice on arrival (queued behind anything playing).
player.enqueue(frame.id, frame.url)
}
+ // The app-shell Ghost Log HUD lives outside this provider's subtree
+ // (mounted once at the app-shell level so it's visible on every
+ // screen — see components/GhostLog.tsx), so it can't read this
+ // context directly. Publish the frames it cares about onto the
+ // shared bus instead.
+ if (frame.type === 'entity') {
+ ghostLogBus.emit('entity', { name: frame.entity.name })
+ } else if (frame.type === 'tell') {
+ ghostLogBus.emit('tell', { text: frame.text })
+ } else if (frame.type === 'ritual_complete') {
+ ghostLogBus.emit('ritual_complete', { success: frame.success, revealed: frame.revealed })
+ }
})
const offState = socket.onState((s) => dispatch({ type: 'connection', state: s }))
@@ -428,6 +441,10 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
offState()
socket.close()
player.clear()
+ // Leaving the séance ends this session as far as the Ghost Log is
+ // concerned — it should fall back to its idle/ambient state on every
+ // other screen rather than showing a stale entity.
+ ghostLogBus.emit('session_end', {})
}
}, [])