diff --git a/backend/app/main.py b/backend/app/main.py
index c2c43c1..bcf72a7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -15,6 +15,7 @@ from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router
from app.routes.device import router as device_router
from app.routes.inventory import router as inventory_router
+from app.routes.seances import router as seances_router
from app.routes.shop import router as shop_router
from app.session_cleanup import delete_expired_sessions
from app.ws import AUDIO_DIR
@@ -97,6 +98,7 @@ app.include_router(auth_router)
app.include_router(codex_router)
app.include_router(device_router)
app.include_router(inventory_router)
+app.include_router(seances_router)
app.include_router(shop_router)
app.include_router(ws_router)
diff --git a/backend/app/routes/seances.py b/backend/app/routes/seances.py
new file mode 100644
index 0000000..1cb1acf
--- /dev/null
+++ b/backend/app/routes/seances.py
@@ -0,0 +1,109 @@
+"""Ghost Log recap — a seeker's own recent séances, with who answered and
+what was said. Workstream C of docs/superpowers/specs/
+2026-07-28-usability-wave-design.md (#7)."""
+
+from collections import defaultdict
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import desc, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import get_db
+from app.deps import get_current_user
+from app.models.contact_session import ContactSession
+from app.models.entity import Entity
+from app.models.event import Event
+from app.models.user import User
+
+router = APIRouter(prefix="/api", tags=["seances"])
+
+RECENT_LIMIT = 12
+ECHOES_PER_SESSION = 3
+
+# Spirit speech lands in the DB as kind="utterance" rows whose payload
+# carries the flavor (see app.ws._speak) — these are the ones worth
+# echoing back in a recap.
+ECHO_PAYLOAD_KINDS = ("greeting", "reply", "manifest")
+
+
+@router.get("/seances/recent")
+async def recent_seances(
+ user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
+):
+ # One query: the user's last sessions with their entity (may be null).
+ rows = (
+ await db.execute(
+ select(ContactSession, Entity)
+ .outerjoin(Entity, ContactSession.entity_id == Entity.id)
+ .where(ContactSession.user_id == user.id)
+ .order_by(desc(ContactSession.started_at))
+ .limit(RECENT_LIMIT)
+ )
+ ).all()
+
+ session_ids = [session.id for session, _ in rows]
+
+ # One GROUP BY across the fetched sessions for event counts by kind.
+ counts: dict = defaultdict(dict)
+ if session_ids:
+ for session_id, kind, count in await db.execute(
+ select(Event.session_id, Event.kind, func.count(Event.id))
+ .where(Event.session_id.in_(session_ids))
+ .group_by(Event.session_id, Event.kind)
+ ):
+ counts[session_id][kind] = count
+
+ # One bounded query for echoes: top-N spirit utterances per session,
+ # newest first, via a window function — never one query per session.
+ echoes: dict = defaultdict(list)
+ if session_ids:
+ ranked = (
+ select(
+ Event.session_id,
+ Event.text,
+ func.row_number()
+ .over(
+ partition_by=Event.session_id,
+ order_by=desc(Event.created_at),
+ )
+ .label("rn"),
+ )
+ .where(
+ Event.session_id.in_(session_ids),
+ Event.kind == "utterance",
+ Event.text.is_not(None),
+ Event.payload["kind"].astext.in_(ECHO_PAYLOAD_KINDS),
+ )
+ .subquery()
+ )
+ for session_id, text, _rn in await db.execute(
+ select(ranked.c.session_id, ranked.c.text, ranked.c.rn).where(
+ ranked.c.rn <= ECHOES_PER_SESSION
+ )
+ ):
+ echoes[session_id].append(text)
+
+ return {
+ "seances": [
+ {
+ "id": str(session.id),
+ "started_at": session.started_at.isoformat(),
+ "ended_at": session.ended_at.isoformat() if session.ended_at else None,
+ "mode": session.mode,
+ "entity": (
+ {
+ "id": str(entity.id),
+ "name": entity.name,
+ "epithet": entity.epithet,
+ "rarity": entity.rarity_tier,
+ "visual": entity.visual_profile,
+ }
+ if entity is not None
+ else None
+ ),
+ "counts": counts.get(session.id, {}),
+ "echoes": echoes.get(session.id, []),
+ }
+ for session, entity in rows
+ ]
+ }
diff --git a/backend/tests/test_seances.py b/backend/tests/test_seances.py
new file mode 100644
index 0000000..acd8abb
--- /dev/null
+++ b/backend/tests/test_seances.py
@@ -0,0 +1,151 @@
+"""Tests for GET /api/seances/recent — the Ghost Log recap
+(usability wave, Workstream C, #7)."""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from app.models.contact_session import ContactSession
+from app.models.entity import Entity
+from app.models.event import Event
+
+
+async def _register(client, username):
+ resp = await client.post(
+ "/auth/register", json={"username": username, "password": "spookyspooky"}
+ )
+ assert resp.status_code == 201
+ return resp.json()["id"]
+
+
+async def _login(client, username):
+ resp = await client.post(
+ "/auth/login", json={"username": username, "password": "spookyspooky"}
+ )
+ assert resp.status_code == 200
+
+
+def _entity(name, signature):
+ return Entity(
+ name=name,
+ epithet="the Test Wraith",
+ rarity_tier="rare",
+ signature=signature,
+ visual_profile={"hue": 200, "form": "wisp"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_unauthenticated_rejected(client):
+ resp = await client.get("/api/seances/recent")
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_only_own_sessions_newest_first_with_counts_and_echoes(client, db_session):
+ my_id = await _register(client, "seeker1")
+ other_id = await _register(client, "seeker2")
+ await _login(client, "seeker1")
+
+ entity = _entity("Testnamed", "sig-aaaa")
+ db_session.add(entity)
+ await db_session.flush()
+
+ now = datetime.now(timezone.utc)
+ older = ContactSession(
+ user_id=my_id, entity_id=entity.id, mode="evp", started_at=now - timedelta(hours=2)
+ )
+ newer = ContactSession(
+ user_id=my_id, entity_id=entity.id, mode="wire", started_at=now - timedelta(hours=1)
+ )
+ theirs = ContactSession(user_id=other_id, mode="ouija", started_at=now)
+ db_session.add_all([older, newer, theirs])
+ await db_session.flush()
+
+ # Events on the newer session: 2 questions, 3 anomalies, 4 spirit
+ # utterances (only the newest 3 should come back as echoes).
+ events = [
+ Event(session_id=newer.id, kind="question", text="who's there?"),
+ Event(session_id=newer.id, kind="question", text="still there?"),
+ Event(session_id=newer.id, kind="anomaly", payload={"score": 1}),
+ Event(session_id=newer.id, kind="anomaly", payload={"score": 2}),
+ Event(session_id=newer.id, kind="anomaly", payload={"score": 3}),
+ ]
+ for i in range(4):
+ events.append(
+ Event(
+ session_id=newer.id,
+ kind="utterance",
+ text=f"echo {i}",
+ payload={"kind": "reply"},
+ created_at=now - timedelta(minutes=30 - i),
+ )
+ )
+ # An utterance on the OTHER user's session must never leak.
+ events.append(
+ Event(
+ session_id=theirs.id,
+ kind="utterance",
+ text="not yours",
+ payload={"kind": "greeting"},
+ )
+ )
+ db_session.add_all(events)
+ await db_session.commit()
+
+ resp = await client.get("/api/seances/recent")
+ assert resp.status_code == 200
+ seances = resp.json()["seances"]
+
+ # Only my sessions, newest first.
+ assert [s["id"] for s in seances] == [str(newer.id), str(older.id)]
+
+ first = seances[0]
+ assert first["mode"] == "wire"
+ assert first["counts"] == {"question": 2, "anomaly": 3, "utterance": 4}
+ assert first["echoes"] == ["echo 3", "echo 2", "echo 1"]
+ assert first["entity"] == {
+ "id": str(entity.id),
+ "name": "Testnamed",
+ "epithet": "the Test Wraith",
+ "rarity": "rare",
+ "visual": {"hue": 200, "form": "wisp"},
+ }
+
+ # No cross-user text anywhere in the response.
+ assert "not yours" not in resp.text
+
+
+@pytest.mark.asyncio
+async def test_session_without_entity_does_not_crash(client, db_session):
+ my_id = await _register(client, "seeker3")
+ await _login(client, "seeker3")
+
+ session = ContactSession(user_id=my_id, mode="ouija")
+ db_session.add(session)
+ await db_session.commit()
+
+ resp = await client.get("/api/seances/recent")
+ assert resp.status_code == 200
+ seances = resp.json()["seances"]
+ assert len(seances) == 1
+ assert seances[0]["entity"] is None
+ assert seances[0]["counts"] == {}
+ assert seances[0]["echoes"] == []
+
+
+@pytest.mark.asyncio
+async def test_limited_to_twelve(client, db_session):
+ my_id = await _register(client, "seeker4")
+ await _login(client, "seeker4")
+
+ now = datetime.now(timezone.utc)
+ db_session.add_all(
+ ContactSession(user_id=my_id, mode="wire", started_at=now - timedelta(minutes=i))
+ for i in range(15)
+ )
+ await db_session.commit()
+
+ resp = await client.get("/api/seances/recent")
+ assert resp.status_code == 200
+ assert len(resp.json()["seances"]) == 12
diff --git a/frontend/index.html b/frontend/index.html
index e10441b..8abf195 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,7 +5,8 @@
Quantumancy
-
+
+
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
new file mode 100644
index 0000000..17f131e
--- /dev/null
+++ b/frontend/public/manifest.webmanifest
@@ -0,0 +1,29 @@
+{
+ "name": "Quantumancy",
+ "short_name": "Quantumancy",
+ "description": "A self-hosted séance — an instrument for speaking with the dead bandwidth.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#07070d",
+ "theme_color": "#07070d",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/apple-touch-icon.png",
+ "sizes": "180x180",
+ "type": "image/png",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 48e9182..c145827 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage'
import { DevicesPage } from './pages/DevicesPage'
import { EnterPage } from './pages/EnterPage'
+import { GhostLogPage } from './pages/GhostLogPage'
import { InventoryPage } from './pages/InventoryPage'
import { LandingPage } from './pages/LandingPage'
import { SeancePage } from './pages/SeancePage'
@@ -28,6 +29,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs
index efd7d59..06fb07e 100644
--- a/frontend/src/i18n/coverage-check.mjs
+++ b/frontend/src/i18n/coverage-check.mjs
@@ -39,6 +39,7 @@ const RULES = [
['codex.rarity.', RARITY],
['codex.sort.', ['recent', 'contacted']],
['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']],
+ ['log.counts.', ['question', 'anomaly', 'utterance']],
['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']],
['landing.modes.', ['radio', 'evp', 'wire', 'ouija']],
['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 0bbdc3d..8be2658 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -17,7 +17,34 @@
"devices": "devices",
"seance": "séance",
"enter": "enter",
- "logout": "depart"
+ "logout": "depart",
+ "log": "log"
+ },
+ "log": {
+ "title": "GHOST LOG",
+ "subtitle": "every séance leaves a page behind",
+ "back": "return to the séance",
+ "loading": "leafing back through the log…",
+ "error": "the log will not open — the veil resists tonight",
+ "empty": "no séances yet — the log waits",
+ "noEntity": "no one answered",
+ "counts": {
+ "question_one": "{{count}} question",
+ "question_other": "{{count}} questions",
+ "anomaly_one": "{{count}} anomaly",
+ "anomaly_other": "{{count}} anomalies",
+ "utterance_one": "{{count}} utterance",
+ "utterance_other": "{{count}} utterances"
+ },
+ "time": {
+ "moments": "moments past",
+ "minutes_one": "a minute past",
+ "minutes_other": "{{count}} minutes past",
+ "hours_one": "an hour past",
+ "hours_other": "{{count}} hours past",
+ "days_one": "a day past",
+ "days_other": "{{count}} days past"
+ }
},
"landing": {
"taglines": [
diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json
index 4f4b118..32e3fe4 100644
--- a/frontend/src/i18n/es.json
+++ b/frontend/src/i18n/es.json
@@ -17,7 +17,34 @@
"devices": "dispositivos",
"seance": "sesión",
"enter": "entrar",
- "logout": "partir"
+ "logout": "partir",
+ "log": "registro"
+ },
+ "log": {
+ "title": "REGISTRO ESPECTRAL",
+ "subtitle": "cada sesión deja una página tras de sí",
+ "back": "volver a la sesión",
+ "loading": "hojeando el registro hacia atrás…",
+ "error": "el registro no se abre — el velo se resiste esta noche",
+ "empty": "aún no hay sesiones — el registro espera",
+ "noEntity": "nadie respondió",
+ "counts": {
+ "question_one": "{{count}} pregunta",
+ "question_other": "{{count}} preguntas",
+ "anomaly_one": "{{count}} anomalía",
+ "anomaly_other": "{{count}} anomalías",
+ "utterance_one": "{{count}} voz",
+ "utterance_other": "{{count}} voces"
+ },
+ "time": {
+ "moments": "hace instantes",
+ "minutes_one": "hace un minuto",
+ "minutes_other": "hace {{count}} minutos",
+ "hours_one": "hace una hora",
+ "hours_other": "hace {{count}} horas",
+ "days_one": "hace un día",
+ "days_other": "hace {{count}} días"
+ }
},
"landing": {
"taglines": [
diff --git a/frontend/src/pages/GhostLogPage.css b/frontend/src/pages/GhostLogPage.css
new file mode 100644
index 0000000..403f34e
--- /dev/null
+++ b/frontend/src/pages/GhostLogPage.css
@@ -0,0 +1,147 @@
+/* Ghost Log page — same tokens as SeancePage.css:
+ bg #07070d · phosphor #7cffb2 · violet #b26bff. */
+
+.ghostlog-page {
+ min-height: 100vh;
+ padding: 2rem 1rem 4rem;
+ background:
+ radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 55%),
+ #07070d;
+ color: #d3e9db;
+ font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
+}
+
+.ghostlog-header {
+ max-width: 680px;
+ margin: 0 auto 1.6rem;
+}
+
+.ghostlog-back {
+ color: #b26bff;
+ text-decoration: none;
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
+}
+
+.ghostlog-back:hover {
+ text-shadow: 0 0 16px rgba(178, 107, 255, 0.9);
+}
+
+.ghostlog-title {
+ margin: 0.9rem 0 0.2rem;
+ font-family: Cinzel, serif;
+ font-size: 1.6rem;
+ letter-spacing: 0.18em;
+ color: #7cffb2;
+ text-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
+}
+
+.ghostlog-subtitle {
+ margin: 0;
+ font-size: 0.78rem;
+ color: rgba(211, 233, 219, 0.6);
+}
+
+.ghostlog-note {
+ max-width: 680px;
+ margin: 2.4rem auto;
+ font-size: 0.85rem;
+ color: rgba(211, 233, 219, 0.65);
+}
+
+.ghostlog-empty {
+ color: #b26bff;
+ text-shadow: 0 0 12px rgba(178, 107, 255, 0.4);
+}
+
+.ghostlog-list {
+ max-width: 680px;
+ margin: 0 auto;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+
+.ghostlog-card {
+ display: flex;
+ gap: 0.9rem;
+ padding: 0.9rem 1rem;
+ background: rgba(16, 16, 26, 0.85);
+ border: 1px solid rgba(124, 255, 178, 0.18);
+ border-radius: 6px;
+}
+
+.ghostlog-glyph {
+ flex: 0 0 auto;
+}
+
+.ghostlog-body {
+ min-width: 0;
+ flex: 1;
+}
+
+.ghostlog-who {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem;
+}
+
+.ghostlog-name {
+ font-family: Cinzel, serif;
+ font-size: 1rem;
+ color: #7cffb2;
+}
+
+.ghostlog-noone {
+ color: rgba(211, 233, 219, 0.5);
+ font-style: italic;
+}
+
+.ghostlog-epithet {
+ font-size: 0.72rem;
+ color: #b26bff;
+}
+
+.ghostlog-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-top: 0.3rem;
+ font-size: 0.72rem;
+ letter-spacing: 0.06em;
+ color: rgba(211, 233, 219, 0.6);
+}
+
+.ghostlog-echoes {
+ margin-top: 0.55rem;
+ border-left: 2px solid rgba(178, 107, 255, 0.35);
+ padding-left: 0.7rem;
+}
+
+.ghostlog-echo {
+ margin: 0.2rem 0;
+ font-size: 0.78rem;
+ color: rgba(124, 255, 178, 0.85);
+ overflow-wrap: break-word;
+}
+
+/* phone widths */
+@media (max-width: 420px) {
+ .ghostlog-page {
+ padding: 1.4rem 0.7rem 3rem;
+ }
+
+ .ghostlog-card {
+ padding: 0.7rem 0.7rem;
+ gap: 0.6rem;
+ }
+
+ .ghostlog-title {
+ font-size: 1.3rem;
+ }
+}
diff --git a/frontend/src/pages/GhostLogPage.tsx b/frontend/src/pages/GhostLogPage.tsx
new file mode 100644
index 0000000..00e491b
--- /dev/null
+++ b/frontend/src/pages/GhostLogPage.tsx
@@ -0,0 +1,153 @@
+// The Ghost Log — a seeker's own recent séances, recalled from
+// GET /api/seances/recent. Usability wave Workstream C (#7).
+
+import { useEffect, useState } from 'react'
+import { Link, Navigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import type { TFunction } from 'i18next'
+import { GhostGlyph } from '../components/GhostGlyph'
+import { useAuth } from '../state/auth'
+import type { GhostForm, Mode } from '../lib/types'
+import './GhostLogPage.css'
+
+type LogEntity = {
+ id: string
+ name: string
+ epithet: string
+ rarity: string
+ visual: { hue?: number; form?: string }
+}
+
+type LogSeance = {
+ id: string
+ started_at: string
+ ended_at: string | null
+ mode: Mode | string
+ entity: LogEntity | null
+ counts: Record
+ echoes: string[]
+}
+
+type LoadState = 'loading' | 'error' | 'ready'
+
+const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const
+const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
+
+function relativeTime(iso: string, t: TFunction): string {
+ const then = new Date(iso).getTime()
+ if (Number.isNaN(then)) return iso
+ const minutes = Math.floor((Date.now() - then) / 60_000)
+ if (minutes < 2) return t('log.time.moments')
+ if (minutes < 60) return t('log.time.minutes', { count: minutes })
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return t('log.time.hours', { count: hours })
+ return t('log.time.days', { count: Math.floor(hours / 24) })
+}
+
+export function GhostLogPage() {
+ const { t } = useTranslation()
+ const { user, checking } = useAuth()
+ const [seances, setSeances] = useState([])
+ const [state, setState] = useState('loading')
+
+ useEffect(() => {
+ if (!user) return
+ const controller = new AbortController()
+ fetch('/api/seances/recent', { credentials: 'include', signal: controller.signal })
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`the log answered ${res.status}`)
+ return (await res.json()) as { seances: LogSeance[] }
+ })
+ .then((data) => {
+ setSeances(data.seances)
+ setState('ready')
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) setState('error')
+ })
+ return () => controller.abort()
+ }, [user])
+
+ if (checking) return null
+ if (!user) return
+
+ return (
+
+
+
+ {t('log.back')}
+
+ {t('log.title')}
+ {t('log.subtitle')}
+
+
+ {state === 'loading' && (
+
+ {t('log.loading')}
+
+ )}
+ {state === 'error' &&
{t('log.error')}
}
+ {state === 'ready' && seances.length === 0 && (
+
{t('log.empty')}
+ )}
+
+ {state === 'ready' && seances.length > 0 && (
+
+ {seances.map((seance) => {
+ const visual = seance.entity?.visual ?? {}
+ const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '')
+ ? (visual.form as GhostForm)
+ : 'wisp'
+ const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0)
+ .map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] }))
+ .join(' · ')
+ return (
+ -
+
+
+
+
+
+ {seance.entity ? (
+ <>
+ {seance.entity.name}
+ {seance.entity.epithet && (
+ {seance.entity.epithet}
+ )}
+ >
+ ) : (
+
+ {t('log.noEntity')}
+
+ )}
+
+
+ {relativeTime(seance.started_at, t)}
+ ·
+ {t(`seance.modes.${seance.mode}`, seance.mode)}
+ {countLine && (
+ <>
+ ·
+ {countLine}
+ >
+ )}
+
+ {seance.echoes.length > 0 && (
+
+ {seance.echoes.map((echo, i) => (
+
+ »
+ {echo}
+
+ ))}
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx
index 33d9656..c7ba21b 100644
--- a/frontend/src/pages/SeancePage.tsx
+++ b/frontend/src/pages/SeancePage.tsx
@@ -269,6 +269,10 @@ function SeanceSession({ username }: { username: string }) {
{t('nav.codex')}
+
+ {t('nav.log', { defaultValue: 'log' })}
+
+
{t('nav.inventory', { defaultValue: 'inventory' })}