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. */} +
+ + + +
-
+ + +