From 859467f46eb8f5becf82984b9b14e8210836a9be Mon Sep 17 00:00:00 2001 From: Indiana Date: Sat, 1 Aug 2026 17:15:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20one=20hamburger,=20every=20page=20?= =?UTF-8?q?=E2=80=94=20mobile=20navigation,=20and=20the=20ghost=20log=20HU?= =?UTF-8?q?D=20is=20gone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.tsx | 10 +- frontend/src/components/GhostLog.css | 314 --------------------- frontend/src/components/GhostLog.test.tsx | 139 --------- frontend/src/components/GhostLog.tsx | 277 ------------------ frontend/src/components/MobileNav.css | 203 +++++++++++++ frontend/src/components/MobileNav.test.tsx | 121 ++++++++ frontend/src/components/MobileNav.tsx | 245 ++++++++++++++++ frontend/src/i18n/en.json | 32 ++- frontend/src/i18n/es.json | 32 ++- frontend/src/lib/ghostLogBus.ts | 53 ---- frontend/src/pages/SeancePage.css | 99 ++++++- frontend/src/pages/SeancePage.tsx | 101 ++++++- frontend/src/state/seance.tsx | 17 -- frontend/src/types/raw.d.ts | 7 + 14 files changed, 816 insertions(+), 834 deletions(-) delete mode 100644 frontend/src/components/GhostLog.css delete mode 100644 frontend/src/components/GhostLog.test.tsx delete mode 100644 frontend/src/components/GhostLog.tsx create mode 100644 frontend/src/components/MobileNav.css create mode 100644 frontend/src/components/MobileNav.test.tsx create mode 100644 frontend/src/components/MobileNav.tsx delete mode 100644 frontend/src/lib/ghostLogBus.ts create mode 100644 frontend/src/types/raw.d.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 090dc3b..bd690a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ // App shell: router + providers + the global CRT overlay (scanlines/vignette) // that sits above every page like old glass. +import { useState } from 'react' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { AuthProvider } from './state/auth' import { HauntingLayer } from './components/HauntingLayer' -import { GhostLog } from './components/GhostLog' +import { MobileNav } from './components/MobileNav' import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexPage } from './pages/CodexPage' import { DevicesPage } from './pages/DevicesPage' @@ -21,6 +22,11 @@ import { SeancePage } from './pages/SeancePage' import { ShopPage } from './pages/ShopPage' 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 ( @@ -29,7 +35,7 @@ function App() {
- + } /> } /> diff --git a/frontend/src/components/GhostLog.css b/frontend/src/components/GhostLog.css deleted file mode 100644 index 0f07d1b..0000000 --- a/frontend/src/components/GhostLog.css +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/components/GhostLog.test.tsx b/frontend/src/components/GhostLog.test.tsx deleted file mode 100644 index d441d30..0000000 --- a/frontend/src/components/GhostLog.test.tsx +++ /dev/null @@ -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 does in -// production: by emitting frames onto the bus and letting React flush. -function emit(type: K, detail: GhostLogEventMap[K]) { - act(() => { - ghostLogBus.emit(type, detail) - }) -} - -describe('GhostLog — idle state', () => { - afterEach(() => { - emit('session_end', {}) - }) - - it('shows ambient status text with no active entity/session', () => { - render() - expect( - screen.getByText('the veil is quiet — no presence detected'), - ).toBeTruthy() - }) - - it('renders no evil-meter gauge while idle', () => { - render() - expect(screen.queryByTestId('evil-meter')).toBeNull() - }) - - it('carries the idle class on the root element', () => { - const { container } = render() - expect(container.querySelector('.ghost-log--idle')).not.toBeNull() - expect(container.querySelector('.ghost-log--active')).toBeNull() - }) -}) - -describe('GhostLog — active session with tells', () => { - afterEach(() => { - emit('session_end', {}) - }) - - it('switches out of idle once an entity frame arrives', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - - expect(screen.queryByText('the veil is quiet — no presence detected')).toBeNull() - expect(screen.getByText('The Hollow Choir')).toBeTruthy() - }) - - it('shows a waiting line before any tells arrive', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - expect(screen.getByText('listening for tells…')).toBeTruthy() - }) - - it('streams tell frames in as log lines, most recent last (Transcript convention)', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - emit('tell', { text: 'the entity avoided a direct question' }) - emit('tell', { text: 'a cold draft crossed the room' }) - - expect(screen.getByText('the entity avoided a direct question')).toBeTruthy() - expect(screen.getByText('a cold draft crossed the room')).toBeTruthy() - - const lines = Array.from(document.querySelectorAll('.ghost-log-line .ghost-log-text')).map( - (el) => el.textContent, - ) - // entity name row + two tell lines, tells in arrival order - expect(lines).toEqual([ - 'The Hollow Choir', - 'the entity avoided a direct question', - 'a cold draft crossed the room', - ]) - }) - - it('renders an evil-meter gauge once active, reflecting the tell history', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - emit('tell', { text: 'the entity avoided a direct question' }) - - const gauge = screen.getByTestId('evil-meter') - expect(gauge).toBeTruthy() - const meterEl = gauge.querySelector('[role="meter"]') - expect(meterEl).not.toBeNull() - expect(meterEl?.getAttribute('aria-valuemin')).toBe('0') - expect(meterEl?.getAttribute('aria-valuemax')).toBe('1') - // starts near the uncertain midpoint before any tells narrow it - expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeGreaterThanOrEqual(0) - expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(1) - }) - - it('snaps the gauge to certainty on a successful ritual_complete', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - emit('tell', { text: 'the entity avoided a direct question' }) - emit('ritual_complete', { success: true, revealed: { alignment: 0.92 } }) - - const gauge = screen.getByTestId('evil-meter') - expect(gauge.className).toContain('evil-meter--certain') - const meterEl = gauge.querySelector('[role="meter"]') - expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeCloseTo(0.92, 3) - }) - - it('does not snap to certainty on a failed ritual_complete', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - emit('ritual_complete', { success: false, revealed: null }) - - const gauge = screen.getByTestId('evil-meter') - expect(gauge.className).not.toContain('evil-meter--certain') - }) - - it('resets back to idle on session_end (leaving the séance)', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - emit('tell', { text: 'a cold draft crossed the room' }) - emit('session_end', {}) - - expect( - screen.getByText('the veil is quiet — no presence detected'), - ).toBeTruthy() - expect(screen.queryByText('a cold draft crossed the room')).toBeNull() - }) - - it('starts a fresh log when a new entity frame supersedes the old one', () => { - render() - emit('entity', { name: 'The Hollow Choir' }) - emit('tell', { text: 'a cold draft crossed the room' }) - emit('entity', { name: 'The Weeping Usher' }) - - expect(screen.getByText('The Weeping Usher')).toBeTruthy() - expect(screen.queryByText('a cold draft crossed the room')).toBeNull() - }) -}) diff --git a/frontend/src/components/GhostLog.tsx b/frontend/src/components/GhostLog.tsx deleted file mode 100644 index bf010a6..0000000 --- a/frontend/src/components/GhostLog.tsx +++ /dev/null @@ -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 publishes onto whenever a -// live séance is running. That indirection exists because the provider is -// only mounted inside the séance route — a shell-level sibling can't read -// its context directly — so this component stays fully functional (idle -// state) on every page with no active WS connection, and comes alive with -// streamed `tell` lines once a séance actually starts. -// -// Visual language: "hacker witch" — Transcript.tsx's terminal/log -// vocabulary (monospace rows, phosphor glow, flicker) fused with occult -// framing (sigil corner ornaments, rune bullets instead of dashes, a -// pentagram divider, a crescent-arc evil-meter gauge with a CRT scanline -// texture standing in for a plain progress bar). - -import { useEffect, useId, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { ghostLogBus } from '../lib/ghostLogBus' -import { computeEvilMeter, evilMeterLabel } from '../lib/evilMeter' -import type { EvilMeterState, RitualReveal } from '../lib/evilMeter' -import './GhostLog.css' - -type TellLine = { id: number; text: string; at: number } - -/** Bounded so a very long séance never grows the HUD's memory unbounded. */ -const TELL_CAP = 60 - -let idSeq = 1 -const nextId = () => idSeq++ - -function clamp01(n: number): number { - return Math.min(1, Math.max(0, n)) -} - -export function GhostLog() { - const { t } = useTranslation() - const [entityName, setEntityName] = useState(null) - const [tells, setTells] = useState([]) - const [ritualReveal, setRitualReveal] = useState(null) - // 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(null) - - useEffect(() => { - // A fresh `entity` event means a new (or newly re-summoned) presence — - // start this HUD's log over rather than carrying the previous entity's - // tells/ritual reveal forward. - const offEntity = ghostLogBus.on('entity', ({ name }) => { - setEntityName(name) - setTells([]) - setRitualReveal(null) - }) - const offTell = ghostLogBus.on('tell', ({ text }) => { - setTells((prev) => { - const next = [...prev, { id: nextId(), text, at: Date.now() }] - return next.length > TELL_CAP ? next.slice(next.length - TELL_CAP) : next - }) - }) - const offRitual = ghostLogBus.on('ritual_complete', (detail) => { - setRitualReveal(detail) - }) - const offEnd = ghostLogBus.on('session_end', () => { - setEntityName(null) - setTells([]) - setRitualReveal(null) - }) - return () => { - offEntity() - offTell() - offRitual() - offEnd() - } - }, []) - - // Same scroll convention as Transcript.tsx: newest line appended at the - // bottom, view auto-scrolls down to follow it. - useEffect(() => { - const box = bodyRef.current - if (box) box.scrollTop = box.scrollHeight - }, [tells.length]) - - const meter = useMemo( - () => computeEvilMeter(tells, ritualReveal), - [tells, ritualReveal], - ) - const active = entityName !== null - - return ( - - ) -} - -export default GhostLog - -// ---- evil-meter gauge: a crescent arc, not a boring progress bar ---- - -type GaugeProps = { meter: EvilMeterState; label: string } - -const CX = 60 -const CY = 56 -const R = 42 -const BAND_THICKNESS = 12 - -function angleForValue(v: number): number { - // 0 (malevolent) -> 180deg (left); 1 (benevolent) -> 0deg (right); the - // sweep from 180 down to 0 traces the *top* semicircle — the crescent. - return 180 - clamp01(v) * 180 -} - -function polar(radius: number, angleDeg: number): { x: number; y: number } { - const rad = (angleDeg * Math.PI) / 180 - return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) } -} - -function EvilMeterGauge({ meter, label }: GaugeProps) { - const { t } = useTranslation() - const patternId = useId() - const { value, spread, certain } = meter - - const outerR = R + BAND_THICKNESS / 2 - const innerR = R - BAND_THICKNESS / 2 - const lowV = clamp01(value - spread) - const highV = clamp01(value + spread) - const lowAngle = angleForValue(lowV) // larger angle (further left) - const highAngle = angleForValue(highV) // smaller angle (further right) - const largeArc = lowAngle - highAngle > 180 ? 1 : 0 - - const outerStart = polar(outerR, lowAngle) - const outerEnd = polar(outerR, highAngle) - const innerEnd = polar(innerR, highAngle) - const innerStart = polar(innerR, lowAngle) - - const bandPath = [ - `M ${outerStart.x} ${outerStart.y}`, - `A ${outerR} ${outerR} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`, - `L ${innerEnd.x} ${innerEnd.y}`, - `A ${innerR} ${innerR} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`, - 'Z', - ].join(' ') - - const trackStart = polar(R, 180) - const trackEnd = polar(R, 0) - const trackPath = `M ${trackStart.x} ${trackStart.y} A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}` - - const needle = polar(R + BAND_THICKNESS / 2 + 4, angleForValue(value)) - - return ( -
- - - - - - - - - - - - - -
- - {label} - -
-
- ) -} diff --git a/frontend/src/components/MobileNav.css b/frontend/src/components/MobileNav.css new file mode 100644 index 0000000..d075b0b --- /dev/null +++ b/frontend/src/components/MobileNav.css @@ -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; + } +} diff --git a/frontend/src/components/MobileNav.test.tsx b/frontend/src/components/MobileNav.test.tsx new file mode 100644 index 0000000..896114e --- /dev/null +++ b/frontend/src/components/MobileNav.test.tsx @@ -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( + + + , + ) + 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(/ 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) + }) +}) diff --git a/frontend/src/components/MobileNav.tsx b/frontend/src/components/MobileNav.tsx new file mode 100644 index 0000000..49fb330 --- /dev/null +++ b/frontend/src/components/MobileNav.tsx @@ -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 + +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(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 ( +
+ + + {open && ( +
onOpenChange(false)} + /> + )} + + +
+ ) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 7914cb0..bca57a2 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -19,7 +19,25 @@ "enter": "enter", "logout": "depart", "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": { "title": "GHOST LOG", @@ -443,6 +461,10 @@ "high": "very little of what it says can be trusted." } } + }, + "nav": { + "open": "more", + "close": "close" } }, "codex": { @@ -689,14 +711,6 @@ "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": { "back": "← the table", "title": "YOUR SIGIL", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index d9aecef..0bb9b95 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -19,7 +19,25 @@ "enter": "entrar", "logout": "partir", "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": { "title": "REGISTRO ESPECTRAL", @@ -443,6 +461,10 @@ "high": "muy poco de lo que dice es de fiar." } } + }, + "nav": { + "open": "más", + "close": "cerrar" } }, "codex": { @@ -689,14 +711,6 @@ "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": { "back": "← la mesa", "title": "TU SIGILO", diff --git a/frontend/src/lib/ghostLogBus.ts b/frontend/src/lib/ghostLogBus.ts deleted file mode 100644 index 601d688..0000000 --- a/frontend/src/lib/ghostLogBus.ts +++ /dev/null @@ -1,53 +0,0 @@ -// GhostLog event bus — mirrors the tiny typed emitter pattern in -// lib/haunting.ts (HauntBus). It exists because is mounted once -// at the app-shell level (visible on every screen) while the live WS session -// lives inside , which is only mounted within the séance -// route/page. React context can't cross that boundary (a shell-level -// sibling can't read a provider nested under a route), so SeanceProvider -// publishes the frames GhostLog cares about onto this bus instead — GhostLog -// subscribes for the lifetime of the app and never needs to sit inside the -// séance's own component tree. - -export type GhostLogEventMap = { - /** A new entity is present (or the session cleared it — entity: null). */ - entity: { name: string | null } - /** One `tell` frame arrived from the server. */ - tell: { text: string } - /** A `ritual_complete` frame arrived. */ - ritual_complete: { success: boolean; revealed: { alignment: number } | null } - /** The active séance session ended (page left / socket torn down) — - * GhostLog should fall back to its idle state. */ - session_end: Record -} - -type GhostLogHandler = (detail: T) => void - -/** Tiny typed emitter — one bad listener never silences the others. */ -export class GhostLogBus { - private handlers = new Map>>() - - on( - type: K, - handler: GhostLogHandler, - ): () => void { - let set = this.handlers.get(type) - if (!set) { - set = new Set() - this.handlers.set(type, set) - } - const h = handler as GhostLogHandler - set.add(h) - return () => { - set.delete(h) - } - } - - emit(type: K, detail: GhostLogEventMap[K]): void { - const set = this.handlers.get(type) - if (!set) return - for (const h of [...set]) (h as GhostLogHandler)(detail) - } -} - -/** The shared bus the app-shell GhostLog and the séance provider both use. */ -export const ghostLogBus = new GhostLogBus() diff --git a/frontend/src/pages/SeancePage.css b/frontend/src/pages/SeancePage.css index 3d053b3..fc325c3 100644 --- a/frontend/src/pages/SeancePage.css +++ b/frontend/src/pages/SeancePage.css @@ -120,6 +120,14 @@ 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 { display: flex; gap: 4px; @@ -128,6 +136,92 @@ 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 { background: transparent; border: 1px solid transparent; @@ -1466,8 +1560,11 @@ 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 { - display: none; + font-size: 0.8rem; + letter-spacing: 0.12em; } } diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index f4fc2d5..d268c0c 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -153,6 +153,45 @@ function SeanceSession({ username }: { username: string }) { const [question, setQuestion] = useState('') const [dossierOpen, setDossierOpen] = useState(true) const [view, setView] = useState('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(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('.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. const machineRef = useRef(new PlanchetteMachine()) @@ -257,20 +296,56 @@ function SeanceSession({ username }: { username: string }) { {t('common.appName')}
- + {/* 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. */} +
+ + + +
-
+ + +