feat: one hamburger, every page — mobile navigation, and the ghost log HUD is gone

The app never had a global navigation. Every page carried its own scattered
links, so on a phone it read as a pile of tiny controls with no map: you
could not tell what existed or where you were. Reported as "everything is
tiny and I cannot see what is going on".

MobileNav is one drawer with every reachable route pegged to exactly one tab,
each with a glyph and a line saying what it is for. It closes on Escape, on
backdrop press, and on any navigation including the back button, and it locks
body scroll — restoring the PREVIOUS value rather than clearing it, since the
séance board legitimately owns overflow during a rite.

Nothing was removed to make room: the whole component is display:none above
860px, so desktop is byte-for-byte unchanged. Detail routes light their
parent tab, so reading a dossier still shows you as "in the codex".

The load-bearing test parses App.tsx's route table and fails if a page exists
that no tab reaches — proven by dropping /devices, which reports "these pages
exist but no mobile tab reaches them: /devices". That is the failure that
actually happens over time.

The floating Ghost Log HUD is removed as requested — it never showed
anything the transcript doesn't already show, and on a 390px screen its fixed
300px panel sat on top of the séance ask-field. Its event bus went with it:
once the HUD was gone the bus had emitters and zero subscribers.

Two things caught while doing this, both mine:
- I first wrote the nav i18n block wholesale and clobbered the existing one,
  which would have silently renamed the Enter page from "the threshold" to
  "the veil" and dropped nav.enter. Merged instead, and verified no original
  value changed. The drawer reuses the existing nav.* vocabulary rather than
  inventing a second set of names for the same places.
- The route-table test first used node:fs, which passes under vitest but
  fails the production build, since tsc typechecks tests and there is no
  @types/node. Uses Vite's ?raw import now — no new dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-01 17:15:01 +00:00
parent c22b2f9c08
commit 859467f46e
14 changed files with 816 additions and 834 deletions

View File

@@ -1,10 +1,11 @@
// App shell: router + providers + the global CRT overlay (scanlines/vignette) // App shell: router + providers + the global CRT overlay (scanlines/vignette)
// that sits above every page like old glass. // that sits above every page like old glass.
import { useState } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './state/auth' import { AuthProvider } from './state/auth'
import { HauntingLayer } from './components/HauntingLayer' import { HauntingLayer } from './components/HauntingLayer'
import { GhostLog } from './components/GhostLog' import { MobileNav } from './components/MobileNav'
import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage' import { CodexPage } from './pages/CodexPage'
import { DevicesPage } from './pages/DevicesPage' import { DevicesPage } from './pages/DevicesPage'
@@ -21,6 +22,11 @@ import { SeancePage } from './pages/SeancePage'
import { ShopPage } from './pages/ShopPage' import { ShopPage } from './pages/ShopPage'
function App() { function App() {
// Owned here rather than inside MobileNav so the drawer's open state can be
// read by anything else in the shell later (the GhostLog HUD, for one, has
// no business sitting on top of an open drawer).
const [navOpen, setNavOpen] = useState(false)
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
@@ -29,7 +35,7 @@ function App() {
<div className="crt-vignette" /> <div className="crt-vignette" />
</div> </div>
<HauntingLayer /> <HauntingLayer />
<GhostLog /> <MobileNav open={navOpen} onOpenChange={setNavOpen} />
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/enter" element={<EnterPage />} /> <Route path="/enter" element={<EnterPage />} />

View File

@@ -1,314 +0,0 @@
/* 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: calc(14px + var(--qm-safe-left, 0px));
/* Clears the iPhone home indicator instead of sitting under it. */
bottom: calc(14px + var(--qm-safe-bottom, 0px));
z-index: 500; /* above page content, below the global CRT glass (9999) and haunting layer (9998) */
width: 300px;
max-width: calc(100vw - 28px - var(--qm-safe-left, 0px) - var(--qm-safe-right, 0px));
max-height: 46vh;
max-height: 46dvh;
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 0.5rem 0.35rem 1rem;
}
.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;
}
}
/* --- collapse control ---------------------------------------------------
The HUD is fixed over the bottom-left of every page. On a desktop that is
dead space; on a 390px phone it lands squarely on top of the séance
ask-field, so there has to be a touch-reachable way to get it out of the
way. */
.ghost-log-toggle {
margin-left: auto;
flex: 0 0 auto;
/* 44x44 — Apple's HIG floor. The glyph is small; the target is not. */
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: rgba(124, 255, 178, 0.7);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.ghost-log-toggle:active {
color: var(--qm-green, #7cffb2);
}
/* Collapsed: header only, so the log is a small tab rather than a panel. */
.ghost-log--collapsed {
max-height: none;
}
/* --- phones -------------------------------------------------------------
A 300px panel on a 390px screen covers most of the width. Span the full
width instead (minus the same gutter), cap the height lower so it never
eats the screen, and keep every row readable. */
@media (max-width: 560px) {
.ghost-log {
left: calc(8px + var(--qm-safe-left, 0px));
right: calc(8px + var(--qm-safe-right, 0px));
bottom: calc(8px + var(--qm-safe-bottom, 0px));
width: auto;
max-width: none;
max-height: 32vh;
max-height: 32dvh;
}
.ghost-log-status,
.ghost-log-body {
font-size: 0.85rem;
}
}

View File

@@ -1,139 +0,0 @@
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 <SeanceProvider> does in
// production: by emitting frames onto the bus and letting React flush.
function emit<K extends keyof GhostLogEventMap>(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(<GhostLog />)
expect(
screen.getByText('the veil is quiet — no presence detected'),
).toBeTruthy()
})
it('renders no evil-meter gauge while idle', () => {
render(<GhostLog />)
expect(screen.queryByTestId('evil-meter')).toBeNull()
})
it('carries the idle class on the root element', () => {
const { container } = render(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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()
})
})

View File

@@ -1,277 +0,0 @@
// 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 <SeanceProvider> 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<string | null>(null)
const [tells, setTells] = useState<TellLine[]>([])
const [ritualReveal, setRitualReveal] = useState<RitualReveal | null>(null)
// On a phone this HUD is pinned over the bottom-left of every page, which
// is exactly where the séance ask-field and its buttons live. It has to be
// collapsible by touch — hovering it away is not an option there.
const [collapsed, setCollapsed] = useState(false)
const bodyRef = useRef<HTMLDivElement | null>(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 (
<aside
className={`ghost-log ${active ? 'ghost-log--active' : 'ghost-log--idle'}${
collapsed ? ' ghost-log--collapsed' : ''
}`}
aria-live="polite"
>
<span className="ghost-log-corner ghost-log-corner--tl" aria-hidden="true">
☽
</span>
<span className="ghost-log-corner ghost-log-corner--tr" aria-hidden="true">
☾
</span>
<header className="ghost-log-header">
<span className="ghost-log-glyph" aria-hidden="true">
⟁
</span>
<span className="ghost-log-title">
{t('ghostLog.title', { defaultValue: 'GHOST LOG' })}
</span>
<button
type="button"
className="ghost-log-toggle"
onClick={() => setCollapsed((c) => !c)}
aria-expanded={!collapsed}
aria-label={
collapsed
? t('ghostLog.expand', { defaultValue: 'open the ghost log' })
: t('ghostLog.collapse', { defaultValue: 'hide the ghost log' })
}
>
<span aria-hidden="true">{collapsed ? '▴' : '▾'}</span>
</button>
</header>
{collapsed ? null : (
<>
{active && <EvilMeterGauge meter={meter} label={evilMeterLabel(meter)} />}
<div className="ghost-log-divider" aria-hidden="true">
<span>·</span>
<span className="ghost-log-divider-sigil">⛧</span>
<span>·</span>
</div>
{!active ? (
<p className="ghost-log-status flicker-soft">
{t('ghostLog.idle', { defaultValue: 'the veil is quiet — no presence detected' })}
</p>
) : (
<div className="ghost-log-body" ref={bodyRef}>
{entityName && (
<div className="ghost-log-line ghost-log-line--system">
<span className="ghost-log-rune" aria-hidden="true">
⟁
</span>
<span className="ghost-log-text ghost-log-entity-name">{entityName}</span>
</div>
)}
{tells.length === 0 ? (
<p className="ghost-log-status dim">
{t('ghostLog.waiting', { defaultValue: 'listening for tells…' })}
</p>
) : (
tells.map((tl) => (
<div key={tl.id} className="ghost-log-line">
<span className="ghost-log-rune" aria-hidden="true">
⚝
</span>
<span className="ghost-log-text">{tl.text}</span>
</div>
))
)}
</div>
)}
</>
)}
</aside>
)
}
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 (
<div
className={`evil-meter${certain ? ' evil-meter--certain' : ''}`}
data-testid="evil-meter"
>
<svg
viewBox="0 0 120 62"
className="evil-meter-svg"
role="meter"
aria-label={t('ghostLog.meterLabel', { defaultValue: 'evil meter' })}
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={Number(value.toFixed(3))}
aria-valuetext={label}
>
<defs>
<pattern
id={patternId}
width="4"
height="4"
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
>
<rect width="4" height="4" fill="transparent" />
<line x1="0" y1="0" x2="0" y2="4" className="evil-meter-scanline" />
</pattern>
</defs>
<path d={trackPath} className="evil-meter-track" />
<path d={bandPath} className={`evil-meter-band${certain ? ' evil-meter-band--certain' : ''}`} />
<path d={bandPath} fill={`url(#${patternId})`} className="evil-meter-band-texture" />
<line
x1={CX}
y1={CY}
x2={needle.x}
y2={needle.y}
className="evil-meter-needle"
/>
<circle cx={CX} cy={CY} r={3} className="evil-meter-hub" />
</svg>
<div className="evil-meter-readout">
<span className="evil-meter-glyph evil-meter-glyph--malevolent" aria-hidden="true">
⛧
</span>
<span className="evil-meter-label">{label}</span>
<span className="evil-meter-glyph evil-meter-glyph--benevolent" aria-hidden="true">
✧
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,203 @@
/* MobileNav — phones only. See MobileNav.tsx for why this exists.
*
* The single most important rule in this file is the media query at the
* bottom: above MOBILE_MAX the entire component is display:none, so desktop
* layout is byte-for-byte what it was. This is additive navigation, not a
* redesign of the app.
*/
.mobile-nav {
--nav-phosphor: #7cffb2;
--nav-violet: #b26bff;
--nav-ink: rgba(8, 8, 14, 0.96);
}
/* ---- the hamburger ---- */
.mobile-nav-toggle {
position: fixed;
/* Below the notch, and clear of the séance's own top-left controls. */
top: calc(env(safe-area-inset-top, 0px) + 0.6rem);
left: calc(env(safe-area-inset-left, 0px) + 0.6rem);
z-index: 1200;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(124, 255, 178, 0.35);
border-radius: 10px;
background: rgba(8, 8, 14, 0.82);
backdrop-filter: blur(6px);
cursor: pointer;
/* Kills the 300ms tap delay without disabling pinch-zoom. */
touch-action: manipulation;
}
.mobile-nav-bars {
display: flex;
flex-direction: column;
gap: 5px;
width: 20px;
}
.mobile-nav-bars span {
display: block;
height: 2px;
border-radius: 2px;
background: var(--nav-phosphor);
transition: transform 160ms ease, opacity 160ms ease;
}
/* Hamburger folds into an X while the drawer is open. */
.mobile-nav[data-open='true'] .mobile-nav-bars span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.mobile-nav[data-open='true'] .mobile-nav-bars span:nth-child(2) {
opacity: 0;
}
.mobile-nav[data-open='true'] .mobile-nav-bars span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* ---- backdrop ---- */
.mobile-nav-backdrop {
position: fixed;
inset: 0;
z-index: 1190;
background: rgba(2, 2, 6, 0.72);
backdrop-filter: blur(2px);
}
/* ---- the drawer ---- */
.mobile-nav-drawer {
position: fixed;
inset: 0;
z-index: 1195;
display: flex;
flex-direction: column;
background: var(--nav-ink);
/* Every inset, not just top/bottom: in landscape the cutout moves to a side. */
padding: calc(env(safe-area-inset-top, 0px) + 1rem)
calc(env(safe-area-inset-right, 0px) + 1rem)
calc(env(safe-area-inset-bottom, 0px) + 1rem)
calc(env(safe-area-inset-left, 0px) + 1rem);
/* The list can exceed a short landscape viewport, so the DRAWER scrolls
* rather than the locked body behind it. */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.mobile-nav-drawer[hidden] {
display: none;
}
.mobile-nav-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
/* Clears the fixed hamburger, which sits over this corner. */
padding-left: 3.2rem;
margin-bottom: 1.1rem;
}
.mobile-nav-title {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.34em;
text-transform: uppercase;
color: var(--nav-violet);
}
.mobile-nav-close {
min-height: 44px;
padding: 0 0.9rem;
border: 1px solid rgba(124, 255, 178, 0.28);
border-radius: 8px;
background: transparent;
color: var(--nav-phosphor);
font-size: 0.82rem;
letter-spacing: 0.08em;
cursor: pointer;
touch-action: manipulation;
}
/* ---- the tabs ---- */
.mobile-nav-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.mobile-nav-item {
display: flex;
align-items: center;
gap: 0.85rem;
/* Comfortably past the 44px minimum: these are the app's primary
* navigation on a phone and should be unmissable, not merely legal. */
min-height: 60px;
padding: 0.55rem 0.75rem;
border: 1px solid transparent;
border-radius: 10px;
background: rgba(18, 18, 28, 0.6);
color: inherit;
text-decoration: none;
}
.mobile-nav-item-active {
border-color: rgba(178, 107, 255, 0.5);
background: rgba(40, 22, 62, 0.72);
}
.mobile-nav-glyph {
flex: 0 0 auto;
width: 30px;
text-align: center;
font-size: 1.2rem;
color: var(--nav-phosphor);
}
.mobile-nav-item-active .mobile-nav-glyph {
color: var(--nav-violet);
}
.mobile-nav-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.mobile-nav-label {
font-size: 1rem;
letter-spacing: 0.04em;
/* Long labels wrap instead of forcing the row wider than the screen. */
overflow-wrap: anywhere;
}
.mobile-nav-blurb {
font-size: 0.8rem;
opacity: 0.62;
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.mobile-nav-bars span {
transition: none;
}
}
/* ---- desktop: gone entirely ----
* Above this width the app's existing per-page navigation has the room it
* was designed for, and this component must not intrude on it at all.
*/
@media (min-width: 861px) {
.mobile-nav {
display: none;
}
}

View File

@@ -0,0 +1,121 @@
// The mobile master-tab drawer.
//
// The load-bearing test here is the LAST one: it reads the route table out of
// App.tsx and fails if a page exists that the drawer cannot reach. Every other
// assertion in this file describes behaviour that a careful reader could
// verify by eye; that one catches the failure that actually happens over time,
// which is somebody adding a page and nobody noticing it is unreachable on a
// phone.
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import '../i18n'
import { MobileNav, NAV_ITEMS, isActive } from './MobileNav'
// Vite inlines the file's text at build time. Node's fs would work under
// vitest but not under `tsc`, which typechecks this file during the
// production build and has no @types/node — and adding that dependency to
// ship one assertion is the wrong trade.
import appSource from '../App.tsx?raw'
function renderNav(open: boolean, at = '/seance') {
const onOpenChange = vi.fn()
const utils = render(
<MemoryRouter initialEntries={[at]}>
<MobileNav open={open} onOpenChange={onOpenChange} />
</MemoryRouter>,
)
return { onOpenChange, ...utils }
}
describe('MobileNav', () => {
it('keeps the drawer out of the accessibility tree until it is opened', () => {
renderNav(false)
expect(screen.queryByRole('dialog')).toBeNull()
// The hamburger itself is always present — it is the only way in.
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
})
it('lists every tab once the drawer is open', () => {
renderNav(true)
const dialog = screen.getByRole('dialog')
const links = within(dialog).getAllByRole('link')
expect(links).toHaveLength(NAV_ITEMS.length)
for (const item of NAV_ITEMS) {
const link = links.find((l) => l.getAttribute('href') === item.to)
expect(link, `no tab pointing at ${item.to}`).toBeTruthy()
}
})
it('marks the current page, and only the current page', () => {
renderNav(true, '/codex')
const current = screen.getAllByRole('link', { current: 'page' })
expect(current).toHaveLength(1)
expect(current[0].getAttribute('href')).toBe('/codex')
})
it('keeps a detail route lit on its parent tab', () => {
// A seeker reading one spirit's dossier is still "in the codex"; a bare
// equality check would leave no tab lit at all and the drawer would look
// broken exactly where it is most needed.
renderNav(true, '/codex/some-entity-id')
const current = screen.getAllByRole('link', { current: 'page' })
expect(current).toHaveLength(1)
expect(current[0].getAttribute('href')).toBe('/codex')
})
it('asks to close on Escape', () => {
const { onOpenChange } = renderNav(true)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not listen for Escape while closed', () => {
// Otherwise every Escape anywhere in the app would fire a state update
// for a drawer nobody opened.
const { onOpenChange } = renderNav(false)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onOpenChange).not.toHaveBeenCalled()
})
it('restores the previous body overflow rather than clearing it', () => {
// The séance board legitimately owns overflow while a rite runs. Clearing
// on close instead of restoring would silently undo that.
document.body.style.overflow = 'clip'
const { unmount } = renderNav(true)
expect(document.body.style.overflow).toBe('hidden')
unmount()
expect(document.body.style.overflow).toBe('clip')
document.body.style.overflow = ''
})
it('reaches every page the router defines', () => {
// Parse App.tsx rather than importing it: importing pulls in the whole
// page tree and its network calls, and the thing under test is the route
// TABLE, which is a source-level fact.
const routed = [...appSource.matchAll(/<Route\s+path="([^"]+)"/g)].map((m) => m[1])
expect(routed.length, 'no routes parsed — the regex has drifted').toBeGreaterThan(5)
const reachable = routed.filter(
(p) =>
p !== '*' && // catch-all, not a destination
!p.includes(':') && // detail views, reached through their index
p !== '/' && // the landing page is the way in, not a tab
p !== '/enter', // the sign-in door, deliberately not a tab
)
const covered = new Set(NAV_ITEMS.map((i) => i.to))
const missing = reachable.filter((p) => !covered.has(p))
expect(
missing,
`these pages exist but no mobile tab reaches them: ${missing.join(', ')}`,
).toEqual([])
})
it('isActive does not let the root match everything', () => {
expect(isActive('/codex', '/')).toBe(false)
expect(isActive('/', '/')).toBe(true)
// A sibling with a shared prefix must not light up.
expect(isActive('/codexical', '/codex')).toBe(false)
})
})

View File

@@ -0,0 +1,245 @@
// MobileNav — the master tab drawer, phones only.
//
// The problem this solves: there was never a global navigation. Every page
// carried its own scattered links, so on a phone the app read as a pile of
// tiny controls with no map — you could not tell what existed or where you
// were. Desktop has the screen width to get away with that. A 390px screen
// does not.
//
// So: one hamburger, one drawer, every page in the app pegged to exactly one
// tab. Nothing is removed and no page loses a feature — this is a second,
// additional way in, and it is display:none above MOBILE_MAX (see the CSS).
// Desktop is deliberately untouched.
//
// The drawer is a modal: it traps nothing (a focus trap would be overkill for
// ten links) but it does close on Escape, on backdrop press, and on any
// navigation, and it locks body scroll while open so the page behind cannot
// slide around under your thumb.
import { useEffect, useRef } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import './MobileNav.css'
type TFn = (key: string, options?: Record<string, unknown>) => string
export type NavItem = {
id: string
to: string
glyph: string
}
/**
* Every route in App.tsx that a seeker can reach directly, in the order they
* matter rather than the order they were built.
*
* `/codex/:id`, `/hunters/:username` and `/messages/:username` are absent on
* purpose: they are detail views reached THROUGH their index page, and a tab
* pointing at a parameterised route has no meaningful destination. Their
* parents are here, so nothing is unreachable.
*/
export const NAV_ITEMS: NavItem[] = [
{ id: 'seance', to: '/seance', glyph: '◈' },
{ id: 'log', to: '/log', glyph: '❋' },
{ id: 'codex', to: '/codex', glyph: '✦' },
{ id: 'hunters', to: '/hunters', glyph: '☾' },
{ id: 'messages', to: '/messages', glyph: '✉' },
{ id: 'profile', to: '/profile', glyph: '◉' },
{ id: 'inventory', to: '/inventory', glyph: '⚱' },
{ id: 'devices', to: '/devices', glyph: '⌁' },
{ id: 'doctrine', to: '/doctrine', glyph: '❂' },
{ id: 'shop', to: '/shop', glyph: '✧' },
]
// These reuse the app's EXISTING nav.* vocabulary rather than inventing a
// second set of names for the same destinations — the drawer should read as
// the same app, and a label change should land in one place.
//
// Literal keys per branch rather than `t(\`nav.${id}\`)`. The i18n coverage
// checker greps source text for literal translation calls and cannot resolve
// an interpolated key without a declared domain rule; a switch is the pattern
// this codebase already uses for exactly this (see PassagePanel's layerTitle
// and RitualPanel's runeCopy).
export function navLabel(id: string, t: TFn): string {
switch (id) {
case 'seance':
return t('nav.seance', { defaultValue: 'séance' })
case 'log':
return t('nav.log', { defaultValue: 'log' })
case 'codex':
return t('nav.codex', { defaultValue: 'codex' })
case 'hunters':
return t('nav.hunters', { defaultValue: 'hunters' })
case 'messages':
return t('nav.messages', { defaultValue: 'messages' })
case 'profile':
return t('nav.profile', { defaultValue: 'profile' })
case 'inventory':
return t('nav.inventory', { defaultValue: 'inventory' })
case 'devices':
return t('nav.devices', { defaultValue: 'devices' })
case 'doctrine':
return t('nav.doctrine', { defaultValue: 'doctrine' })
case 'shop':
default:
return t('nav.shop', { defaultValue: 'shop' })
}
}
export function navBlurb(id: string, t: TFn): string {
switch (id) {
case 'seance':
return t('nav.blurb.seance', { defaultValue: 'open a channel and call' })
case 'log':
return t('nav.blurb.log', { defaultValue: 'what the room has said' })
case 'codex':
return t('nav.blurb.codex', { defaultValue: 'every spirit ever named' })
case 'hunters':
return t('nav.blurb.hunters', { defaultValue: 'others who do this' })
case 'messages':
return t('nav.blurb.messages', { defaultValue: 'word from other hunters' })
case 'profile':
return t('nav.blurb.profile', { defaultValue: 'your standing and rank' })
case 'inventory':
return t('nav.blurb.inventory', { defaultValue: 'what you have kept' })
case 'devices':
return t('nav.blurb.devices', { defaultValue: 'pair a sensor node' })
case 'doctrine':
return t('nav.blurb.doctrine', { defaultValue: 'how the instrument works' })
case 'shop':
default:
return t('nav.blurb.shop', { defaultValue: 'instruments to come' })
}
}
/** True when `pathname` is inside `to` — so `/codex/abc` still lights the
* codex tab. `/` is exact-only, or it would match everything. */
export function isActive(pathname: string, to: string): boolean {
if (to === '/') return pathname === '/'
return pathname === to || pathname.startsWith(`${to}/`)
}
export function MobileNav({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (next: boolean) => void
}) {
const { t } = useTranslation()
const location = useLocation()
const panelRef = useRef<HTMLDivElement | null>(null)
const previousPath = useRef(location.pathname)
// Close on navigation. Keyed on pathname rather than on the click handler
// so a link followed from anywhere — including the browser's back button —
// still leaves the drawer shut behind it.
useEffect(() => {
if (previousPath.current !== location.pathname) {
previousPath.current = location.pathname
onOpenChange(false)
}
}, [location.pathname, onOpenChange])
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onOpenChange(false)
}
window.addEventListener('keydown', onKey)
// Lock the page behind the drawer. Restoring the PREVIOUS value rather
// than clearing it matters: another component may legitimately own
// overflow (the séance board does while a rite is running), and clearing
// would silently undo it.
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKey)
document.body.style.overflow = previousOverflow
}
}, [open, onOpenChange])
// Move focus into the drawer when it opens, so a keyboard or screen-reader
// user lands inside it rather than continuing behind it.
useEffect(() => {
if (open) panelRef.current?.focus()
}, [open])
return (
<div className="mobile-nav" data-open={open}>
<button
type="button"
className="mobile-nav-toggle"
aria-expanded={open}
aria-controls="mobile-nav-drawer"
aria-label={
open
? t('nav.closeMenu', { defaultValue: 'close the ways' })
: t('nav.openMenu', { defaultValue: 'the ways' })
}
onClick={() => onOpenChange(!open)}
>
<span className="mobile-nav-bars" aria-hidden>
<span />
<span />
<span />
</span>
</button>
{open && (
<div
className="mobile-nav-backdrop"
// Presentational: the real dismissal affordances are the labelled
// close button inside the drawer and the Escape key, both of which
// a keyboard user can reach. This is a pointer convenience.
role="presentation"
onClick={() => onOpenChange(false)}
/>
)}
<div
className="mobile-nav-drawer"
id="mobile-nav-drawer"
role="dialog"
aria-modal="true"
aria-label={t('nav.title', { defaultValue: 'the ways' })}
hidden={!open}
tabIndex={-1}
ref={panelRef}
>
<div className="mobile-nav-head">
<h2 className="mobile-nav-title">{t('nav.title', { defaultValue: 'the ways' })}</h2>
<button
type="button"
className="mobile-nav-close"
onClick={() => onOpenChange(false)}
>
{t('nav.closeMenu', { defaultValue: 'close the ways' })}
</button>
</div>
<nav className="mobile-nav-list">
{NAV_ITEMS.map((item) => {
const active = isActive(location.pathname, item.to)
return (
<Link
key={item.id}
to={item.to}
className={`mobile-nav-item${active ? ' mobile-nav-item-active' : ''}`}
aria-current={active ? 'page' : undefined}
>
<span className="mobile-nav-glyph" aria-hidden>
{item.glyph}
</span>
<span className="mobile-nav-text">
<span className="mobile-nav-label">{navLabel(item.id, t)}</span>
<span className="mobile-nav-blurb">{navBlurb(item.id, t)}</span>
</span>
</Link>
)
})}
</nav>
</div>
</div>
)
}

View File

@@ -19,7 +19,25 @@
"enter": "enter", "enter": "enter",
"logout": "depart", "logout": "depart",
"log": "log", "log": "log",
"hunters": "hunters" "hunters": "hunters",
"title": "the ways",
"openMenu": "the ways",
"closeMenu": "close the ways",
"messages": "messages",
"profile": "profile",
"doctrine": "doctrine",
"blurb": {
"seance": "open a channel and call",
"log": "what the room has said",
"codex": "every spirit ever named",
"hunters": "others who do this",
"messages": "word from other hunters",
"profile": "your standing and rank",
"inventory": "what you have kept",
"devices": "pair a sensor node",
"doctrine": "how the instrument works",
"shop": "instruments to come"
}
}, },
"log": { "log": {
"title": "GHOST LOG", "title": "GHOST LOG",
@@ -443,6 +461,10 @@
"high": "very little of what it says can be trusted." "high": "very little of what it says can be trusted."
} }
} }
},
"nav": {
"open": "more",
"close": "close"
} }
}, },
"codex": { "codex": {
@@ -689,14 +711,6 @@
"listening": "…listening…" "listening": "…listening…"
} }
}, },
"ghostLog": {
"title": "GHOST LOG",
"idle": "the veil is quiet — no presence detected",
"waiting": "listening for tells…",
"meterLabel": "evil meter",
"collapse": "hide the ghost log",
"expand": "open the ghost log"
},
"profile": { "profile": {
"back": "← the table", "back": "← the table",
"title": "YOUR SIGIL", "title": "YOUR SIGIL",

View File

@@ -19,7 +19,25 @@
"enter": "entrar", "enter": "entrar",
"logout": "partir", "logout": "partir",
"log": "registro", "log": "registro",
"hunters": "lista" "hunters": "lista",
"title": "los caminos",
"openMenu": "los caminos",
"closeMenu": "cerrar los caminos",
"messages": "mensajes",
"profile": "perfil",
"doctrine": "doctrina",
"blurb": {
"seance": "abre un canal y llama",
"log": "lo que la sala ha dicho",
"codex": "cada espíritu nombrado",
"hunters": "otros que hacen esto",
"messages": "palabra de otros cazadores",
"profile": "tu rango y posición",
"inventory": "lo que has guardado",
"devices": "vincula un sensor",
"doctrine": "cómo funciona el instrumento",
"shop": "instrumentos por venir"
}
}, },
"log": { "log": {
"title": "REGISTRO ESPECTRAL", "title": "REGISTRO ESPECTRAL",
@@ -443,6 +461,10 @@
"high": "muy poco de lo que dice es de fiar." "high": "muy poco de lo que dice es de fiar."
} }
} }
},
"nav": {
"open": "más",
"close": "cerrar"
} }
}, },
"codex": { "codex": {
@@ -689,14 +711,6 @@
"listening": "…escuchando…" "listening": "…escuchando…"
} }
}, },
"ghostLog": {
"title": "REGISTRO FANTASMA",
"idle": "el velo está en silencio — no se detecta presencia",
"waiting": "escuchando indicios…",
"meterLabel": "medidor de maldad",
"collapse": "ocultar el registro fantasma",
"expand": "abrir el registro fantasma"
},
"profile": { "profile": {
"back": "← la mesa", "back": "← la mesa",
"title": "TU SIGILO", "title": "TU SIGILO",

View File

@@ -1,53 +0,0 @@
// GhostLog event bus — mirrors the tiny typed emitter pattern in
// lib/haunting.ts (HauntBus). It exists because <GhostLog/> is mounted once
// at the app-shell level (visible on every screen) while the live WS session
// lives inside <SeanceProvider>, 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<string, never>
}
type GhostLogHandler<T> = (detail: T) => void
/** Tiny typed emitter — one bad listener never silences the others. */
export class GhostLogBus {
private handlers = new Map<keyof GhostLogEventMap, Set<GhostLogHandler<never>>>()
on<K extends keyof GhostLogEventMap>(
type: K,
handler: GhostLogHandler<GhostLogEventMap[K]>,
): () => void {
let set = this.handlers.get(type)
if (!set) {
set = new Set()
this.handlers.set(type, set)
}
const h = handler as GhostLogHandler<never>
set.add(h)
return () => {
set.delete(h)
}
}
emit<K extends keyof GhostLogEventMap>(type: K, detail: GhostLogEventMap[K]): void {
const set = this.handlers.get(type)
if (!set) return
for (const h of [...set]) (h as GhostLogHandler<GhostLogEventMap[K]>)(detail)
}
}
/** The shared bus the app-shell GhostLog and the séance provider both use. */
export const ghostLogBus = new GhostLogBus()

View File

@@ -120,6 +120,14 @@
text-shadow: 0 0 10px rgba(178, 107, 255, 0.7); text-shadow: 0 0 10px rgba(178, 107, 255, 0.7);
} }
/* The rail wraps the scrolling mode tabs and hosts the edge cues. */
.seance-modes-rail {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: flex;
}
.seance-modes { .seance-modes {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -128,6 +136,92 @@
min-width: 0; min-width: 0;
} }
/* Candle-smoke fades at whichever edge still has tabs behind it. Purely a
cue: the rail scrolls by touch, and the arrows below are tappable too. */
.seance-modes-rail::before,
.seance-modes-rail::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 34px;
pointer-events: none;
opacity: 0;
transition: opacity 180ms ease;
z-index: 1;
}
.seance-modes-rail::before {
left: 0;
background: linear-gradient(90deg, rgba(16, 16, 26, 0.95), rgba(16, 16, 26, 0));
}
.seance-modes-rail::after {
right: 0;
background: linear-gradient(270deg, rgba(16, 16, 26, 0.95), rgba(16, 16, 26, 0));
}
.seance-modes-rail[data-more-start='true']::before,
.seance-modes-rail[data-more-end='true']::after {
opacity: 1;
}
.seance-modes-cue {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 180ms ease;
font-size: 0.95rem;
line-height: 1;
color: #b26bff;
text-shadow: 0 0 10px rgba(178, 107, 255, 0.75);
animation: sigilPulse 2.4s ease-in-out infinite;
}
.seance-modes-cue--start {
left: 1px;
}
.seance-modes-cue--end {
right: 1px;
}
.seance-modes-rail[data-more-start='true'] .seance-modes-cue--start,
.seance-modes-rail[data-more-end='true'] .seance-modes-cue--end {
opacity: 0.9;
}
@media (prefers-reduced-motion: reduce) {
.seance-modes-cue {
animation: none;
}
}
/* The extra topbar rail (links, language, passive listening, departure) is
always open at desktop widths; below 560px it becomes a disclosure driven
by this button, which is hidden here because there is room for the rail
itself. Nothing is removed at either width. */
.seance-nav-toggle {
display: none;
align-items: center;
gap: 0.4rem;
min-height: 44px;
min-width: 44px;
padding: 0 0.6rem;
background: rgba(178, 107, 255, 0.08);
border: 1px solid rgba(178, 107, 255, 0.4);
border-radius: 3px;
color: #b26bff;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-shadow: 0 0 10px rgba(178, 107, 255, 0.5);
}
.mode-tab { .mode-tab {
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
@@ -1466,8 +1560,11 @@
gap: 0.6rem; gap: 0.6rem;
} }
/* The brand stays — a tablet has room for it, it just doesn't need to be
the loudest thing in the bar. */
.seance-brand { .seance-brand {
display: none; font-size: 0.8rem;
letter-spacing: 0.12em;
} }
} }

View File

@@ -153,6 +153,45 @@ function SeanceSession({ username }: { username: string }) {
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true) const [dossierOpen, setDossierOpen] = useState(true)
const [view, setView] = useState<BoardView>('board') const [view, setView] = useState<BoardView>('board')
// The topbar's links/toggles don't fit a phone in one row. Rather than
// dropping any of them, they live behind this disclosure below 560px; at
// desktop widths the rail is always shown and the toggle is not rendered
// visible by CSS, so nothing changes there.
const [navOpen, setNavOpen] = useState(false)
// Six mode tabs never fit a 390px viewport, so the rail scrolls. A hidden
// scrollbar with no other affordance made the later modes (camera above
// all) look like they simply didn't exist on a phone — these flags drive
// the fade-and-arrow cues at whichever edge still has tabs behind it.
const modesRef = useRef<HTMLElement | null>(null)
const [modeOverflow, setModeOverflow] = useState({ start: false, end: false })
const syncModeOverflow = useCallback(() => {
const el = modesRef.current
if (!el) return
// 2px of slack: sub-pixel layout rounding otherwise leaves a permanent
// "more this way" cue pointing at nothing.
const start = el.scrollLeft > 2
const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 2
setModeOverflow((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
}, [])
useEffect(() => {
syncModeOverflow()
if (typeof window === 'undefined') return
window.addEventListener('resize', syncModeOverflow)
return () => window.removeEventListener('resize', syncModeOverflow)
}, [syncModeOverflow])
// Keep the active tab on screen when the mode changes from anywhere else
// (a deep link, or the rail being scrolled away from the current mode).
useEffect(() => {
const el = modesRef.current
const active = el?.querySelector<HTMLElement>('.mode-tab.active')
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
syncModeOverflow()
}, [state.mode, syncModeOverflow])
// The planchette machine lives for the whole session; the board ticks it. // The planchette machine lives for the whole session; the board ticks it.
const machineRef = useRef(new PlanchetteMachine()) const machineRef = useRef(new PlanchetteMachine())
@@ -257,7 +296,21 @@ function SeanceSession({ username }: { username: string }) {
{t('common.appName')} {t('common.appName')}
</div> </div>
<nav className="seance-modes" aria-label="modes"> {/* The rail keeps the six modes on one line and scrolls them; the
wrapper carries the fade + arrow cues so the off-screen ones stay
discoverable. Wrapping to two rows was the alternative, but that
steals ~44px of board height on the screen that has the least. */}
<div
className="seance-modes-rail"
data-more-start={modeOverflow.start}
data-more-end={modeOverflow.end}
>
<nav
className="seance-modes"
aria-label="modes"
ref={modesRef}
onScroll={syncModeOverflow}
>
{MODES.map((m) => ( {MODES.map((m) => (
<button <button
key={m} key={m}
@@ -269,8 +322,30 @@ function SeanceSession({ username }: { username: string }) {
</button> </button>
))} ))}
</nav> </nav>
<span className="seance-modes-cue seance-modes-cue--start" aria-hidden="true">
‹
</span>
<span className="seance-modes-cue seance-modes-cue--end" aria-hidden="true">
›
</span>
</div>
<div className="seance-top-right"> <button
type="button"
className="seance-nav-toggle"
aria-expanded={navOpen}
aria-controls="seance-nav-rail"
onClick={() => setNavOpen((o) => !o)}
>
<span aria-hidden="true">⛤</span>
<span className="seance-nav-toggle-label">
{navOpen
? t('seance.nav.close', { defaultValue: 'close' })
: t('seance.nav.open', { defaultValue: 'more' })}
</span>
</button>
<div className="seance-top-right" id="seance-nav-rail" data-open={navOpen}>
<label className="passive-toggle" title={t('seance.passive')}> <label className="passive-toggle" title={t('seance.passive')}>
<input <input
type="checkbox" type="checkbox"

View File

@@ -15,7 +15,6 @@ import type { ReactNode } from 'react'
import { VeilSocket } from '../lib/ws' import { VeilSocket } from '../lib/ws'
import type { VeilConnectionState } from '../lib/ws' import type { VeilConnectionState } from '../lib/ws'
import { SpiritAudioPlayer } from '../lib/audio' import { SpiritAudioPlayer } from '../lib/audio'
import { ghostLogBus } from '../lib/ghostLogBus'
import { EntropyPool } from '../lib/entropy' import { EntropyPool } from '../lib/entropy'
import { PASSAGE_LAYERS } from '../lib/types' import { PASSAGE_LAYERS } from '../lib/types'
import type { import type {
@@ -712,18 +711,6 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
// Auto-play spirit voice on arrival (queued behind anything playing). // Auto-play spirit voice on arrival (queued behind anything playing).
player.enqueue(frame.id, frame.url) 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 })) const offState = socket.onState((s) => dispatch({ type: 'connection', state: s }))
@@ -738,10 +725,6 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
offState() offState()
socket.close() socket.close()
player.clear() 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', {})
} }
}, []) }, [])

7
frontend/src/types/raw.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// Vite's `?raw` suffix imports a file's contents as a string. Declared here
// because tsconfig sets an explicit `types` list, which excludes the ambient
// `vite/client` declarations that would otherwise provide this.
declare module '*?raw' {
const content: string
export default content
}