feat: presence beyond the tab — PWA manifest and the Ghost Log recap

Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
  index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
  entity, per-kind event counts (one GROUP BY) and up to 3 spirit
  echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
  timestamps, counts and echo lines; LOG link in the séance topbar;
  en/es i18n parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 17:19:55 +00:00
parent 2e2c64d36b
commit 3b33b5d6f6
12 changed files with 656 additions and 3 deletions

View File

@@ -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)

View File

@@ -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
]
}

View File

@@ -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

View File

@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quantumancy</title>
<meta name="description" content="A self-hosted séance. Talk to spirits through your microphone, an RTL-SDR dongle, your network's jitter, your phone's motion sensors, or a text box — a locally-run LLM gives each anomaly a voice." />
<meta name="theme-color" content="#0b0a10" />
<meta name="theme-color" content="#07070d" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />

View File

@@ -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"
}
]
}

View File

@@ -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() {
<Route path="/" element={<LandingPage />} />
<Route path="/enter" element={<EnterPage />} />
<Route path="/seance" element={<SeancePage />} />
<Route path="/log" element={<GhostLogPage />} />
<Route path="/codex" element={<CodexPage />} />
<Route path="/codex/:id" element={<CodexEntityPage />} />
<Route path="/shop" element={<ShopPage />} />

View File

@@ -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']],

View File

@@ -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": [

View File

@@ -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": [

View File

@@ -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;
}
}

View File

@@ -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<string, number>
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<LogSeance[]>([])
const [state, setState] = useState<LoadState>('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 <Navigate to="/enter" replace />
return (
<div className="ghostlog-page">
<header className="ghostlog-header">
<Link to="/seance" className="ghostlog-back">
{t('log.back')}
</Link>
<h1 className="ghostlog-title">{t('log.title')}</h1>
<p className="ghostlog-subtitle">{t('log.subtitle')}</p>
</header>
{state === 'loading' && (
<p className="ghostlog-note" role="status">
{t('log.loading')}
</p>
)}
{state === 'error' && <p className="ghostlog-note">{t('log.error')}</p>}
{state === 'ready' && seances.length === 0 && (
<p className="ghostlog-note ghostlog-empty">{t('log.empty')}</p>
)}
{state === 'ready' && seances.length > 0 && (
<ul className="ghostlog-list">
{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 (
<li key={seance.id} className="ghostlog-card">
<div className="ghostlog-glyph">
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
</div>
<div className="ghostlog-body">
<div className="ghostlog-who">
{seance.entity ? (
<>
<span className="ghostlog-name">{seance.entity.name}</span>
{seance.entity.epithet && (
<span className="ghostlog-epithet">{seance.entity.epithet}</span>
)}
</>
) : (
<span className="ghostlog-name ghostlog-noone">
{t('log.noEntity')}
</span>
)}
</div>
<div className="ghostlog-meta">
<span>{relativeTime(seance.started_at, t)}</span>
<span aria-hidden>·</span>
<span>{t(`seance.modes.${seance.mode}`, seance.mode)}</span>
{countLine && (
<>
<span aria-hidden>·</span>
<span>{countLine}</span>
</>
)}
</div>
{seance.echoes.length > 0 && (
<div className="ghostlog-echoes">
{seance.echoes.map((echo, i) => (
<p key={i} className="ghostlog-echo">
<span aria-hidden>» </span>
{echo}
</p>
))}
</div>
)}
</div>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -264,6 +264,10 @@ function SeanceSession({ username }: { username: string }) {
{t('nav.codex')}
</Link>
<Link to="/log" className="seance-codex-link">
{t('nav.log', { defaultValue: 'log' })}
</Link>
<Link to="/inventory" className="seance-codex-link">
{t('nav.inventory', { defaultValue: 'inventory' })}
</Link>