From eaa28b20e8ac3298ad24f1a6dd14e7bc9febd333 Mon Sep 17 00:00:00 2001 From: Indiana Date: Tue, 28 Jul 2026 17:31:10 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20legibility=20wave=20=E2=80=94=20veil=20?= =?UTF-8?q?conditions,=20first-run=20hints,=20kinder=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workstream B of the usability wave (#2 hints, #3 conditions, #4 errors): - GET /api/conditions (new backend/app/routes/conditions.py): composes celestial veil_thinness with the cached NOAA Kp reading; lenient lon parsing (junk degrades to moon-only, never 422); geomagnetic may be null on a cold cache. Route tests stub the cache — no live NOAA calls. - VeilConditions strip in the séance side column: moon glyph + phase, % lit, veil-thinness phrase, Kp line only when data exists. Polls every 10 min; renders nothing while loading; no error state. - ModeHint: per-mode in-fiction one-liner after ~15s idle, suppressed once the mode's sensor runs this session, dismissal persisted in localStorage (qm_hint_). One mount line per panel. - Error copy upgraded to detect-and-redirect: mic denied points at site settings and the ouija board/wire; WebUSB-unsupported suggests EVP. - i18n en/es parity for every new string; coverage-check template domains extended for the new template-key call sites. Co-Authored-By: Claude Fable 5 --- backend/app/main.py | 2 + backend/app/routes/conditions.py | 46 +++++++++ backend/tests/test_conditions.py | 73 ++++++++++++++ frontend/src/components/ModeHint.css | 66 +++++++++++++ frontend/src/components/ModeHint.test.tsx | 43 +++++++++ frontend/src/components/ModeHint.tsx | 76 +++++++++++++++ frontend/src/components/VeilConditions.css | 54 +++++++++++ .../src/components/VeilConditions.test.tsx | 50 ++++++++++ frontend/src/components/VeilConditions.tsx | 95 +++++++++++++++++++ frontend/src/i18n/coverage-check.mjs | 6 ++ frontend/src/i18n/en.json | 32 ++++++- frontend/src/i18n/es.json | 32 ++++++- frontend/src/pages/SeancePage.tsx | 8 ++ 13 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 backend/app/routes/conditions.py create mode 100644 backend/tests/test_conditions.py create mode 100644 frontend/src/components/ModeHint.css create mode 100644 frontend/src/components/ModeHint.test.tsx create mode 100644 frontend/src/components/ModeHint.tsx create mode 100644 frontend/src/components/VeilConditions.css create mode 100644 frontend/src/components/VeilConditions.test.tsx create mode 100644 frontend/src/components/VeilConditions.tsx diff --git a/backend/app/main.py b/backend/app/main.py index c2c43c1..712aa2e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from app.config import settings from app.db import Base, async_session_maker, engine from app.routes.auth import router as auth_router from app.routes.codex import router as codex_router +from app.routes.conditions import router as conditions_router from app.routes.device import router as device_router from app.routes.inventory import router as inventory_router from app.routes.shop import router as shop_router @@ -95,6 +96,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Quantumancy", lifespan=lifespan) app.include_router(auth_router) app.include_router(codex_router) +app.include_router(conditions_router) app.include_router(device_router) app.include_router(inventory_router) app.include_router(shop_router) diff --git a/backend/app/routes/conditions.py b/backend/app/routes/conditions.py new file mode 100644 index 0000000..27934e0 --- /dev/null +++ b/backend/app/routes/conditions.py @@ -0,0 +1,46 @@ +"""Veil conditions readout — the sky and the field, in one call. + +Composes the two real-world signals the app already computes: + + sky - app.celestial.veil_thinness(): moon phase/illumination and + (when a longitude is offered) proximity to true solar + midnight. Pure math, always present. + geomagnetic - app.geomagnetic.geomagnetic_cache.reading(): NOAA's + planetary Kp from cache. May be None on a cold cache or + when NOAA is unreachable — that is a valid answer, not an + error, and the frontend simply shows fewer lines. + +`lon` is accepted as a raw string and parsed by hand so a malformed or +out-of-range value degrades to the moon-only reading instead of a 422 — +the conditions strip must never fail because a browser sent junk. +""" + +import math + +from fastapi import APIRouter + +from app.celestial import veil_thinness +from app.geomagnetic import geomagnetic_cache + +router = APIRouter(prefix="/api", tags=["conditions"]) + + +def _parse_longitude(raw: str | None) -> float | None: + """A usable longitude in [-180, 180], or None for anything else.""" + if raw is None: + return None + try: + lon = float(raw) + except (TypeError, ValueError): + return None + if not math.isfinite(lon) or not -180.0 <= lon <= 180.0: + return None + return lon + + +@router.get("/conditions") +async def get_conditions(lon: str | None = None): + return { + "sky": veil_thinness(_parse_longitude(lon)), + "geomagnetic": await geomagnetic_cache.reading(), + } diff --git a/backend/tests/test_conditions.py b/backend/tests/test_conditions.py new file mode 100644 index 0000000..a03bc13 --- /dev/null +++ b/backend/tests/test_conditions.py @@ -0,0 +1,73 @@ +"""GET /api/conditions — sky always present, geomagnetic nullable, lon lenient. + +The geomagnetic cache is stubbed in every test so nothing here ever +touches NOAA; a cold real cache would otherwise fire a live HTTP request. +""" + +import pytest + +import app.routes.conditions as conditions_module + + +class _StubCache: + def __init__(self, reading): + self._reading = reading + + async def reading(self): + return self._reading + + +@pytest.fixture +def geo_null(monkeypatch): + monkeypatch.setattr(conditions_module, "geomagnetic_cache", _StubCache(None)) + + +@pytest.fixture +def geo_storm(monkeypatch): + monkeypatch.setattr( + conditions_module, + "geomagnetic_cache", + _StubCache({"kp": 6.33, "label": "moderate geomagnetic storm", + "disturbance": 6.33 / 9.0, "storm": True}), + ) + + +@pytest.mark.asyncio +async def test_sky_always_present_geomagnetic_null_tolerated(client, geo_null): + resp = await client.get("/api/conditions") + assert resp.status_code == 200 + body = resp.json() + assert body["geomagnetic"] is None + sky = body["sky"] + assert sky["moon_name"] + assert 0.0 <= sky["moon_illumination"] <= 1.0 + assert 0.0 <= sky["thinness"] <= 1.0 + # No longitude offered — witching proximity is unknowable. + assert sky["witching_proximity"] is None + + +@pytest.mark.asyncio +async def test_valid_lon_yields_witching_proximity(client, geo_null): + resp = await client.get("/api/conditions", params={"lon": "-3.7"}) + assert resp.status_code == 200 + witching = resp.json()["sky"]["witching_proximity"] + assert witching is not None + assert 0.0 <= witching <= 1.0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad", ["ghost", "", "nan", "inf", "999", "-181"]) +async def test_invalid_lon_ignored_not_422(client, geo_null, bad): + resp = await client.get("/api/conditions", params={"lon": bad}) + assert resp.status_code == 200 + assert resp.json()["sky"]["witching_proximity"] is None + + +@pytest.mark.asyncio +async def test_geomagnetic_reading_passed_through(client, geo_storm): + resp = await client.get("/api/conditions") + assert resp.status_code == 200 + geo = resp.json()["geomagnetic"] + assert geo["kp"] == pytest.approx(6.33) + assert geo["storm"] is True + assert geo["label"] == "moderate geomagnetic storm" diff --git a/frontend/src/components/ModeHint.css b/frontend/src/components/ModeHint.css new file mode 100644 index 0000000..0b29c5b --- /dev/null +++ b/frontend/src/components/ModeHint.css @@ -0,0 +1,66 @@ +/* ModeHint — one whispered line inside a mode panel. + Tokens follow SeancePage.css: phosphor #7cffb2 · violet #b26bff · + panel rgba(16,16,26,0.85) · border rgba(124,255,178,0.18). */ + +.mode-hint { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-top: 0.6rem; + padding: 0.5rem 0.6rem; + border: 1px dashed rgba(178, 107, 255, 0.35); + border-radius: 4px; + background: rgba(16, 16, 26, 0.85); + color: #d3e9db; + font-size: 0.78rem; + line-height: 1.45; + animation: modeHintRise 0.9s ease-out both; +} + +.mode-hint-glyph { + color: #b26bff; + text-shadow: 0 0 8px rgba(178, 107, 255, 0.7); + flex: none; +} + +.mode-hint-text { + flex: 1; + min-width: 0; + opacity: 0.85; + font-style: italic; +} + +.mode-hint-dismiss { + flex: none; + border: 0; + background: none; + color: #7cffb2; + opacity: 0.6; + font: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.1rem; +} + +.mode-hint-dismiss:hover, +.mode-hint-dismiss:focus-visible { + opacity: 1; +} + +@keyframes modeHintRise { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .mode-hint { + animation: none; + } +} diff --git a/frontend/src/components/ModeHint.test.tsx b/frontend/src/components/ModeHint.test.tsx new file mode 100644 index 0000000..15c2d7b --- /dev/null +++ b/frontend/src/components/ModeHint.test.tsx @@ -0,0 +1,43 @@ +import { act, render, screen, fireEvent } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import '../i18n' +import { ModeHint } from './ModeHint' + +describe('ModeHint', () => { + beforeEach(() => { + vi.useFakeTimers() + window.localStorage.clear() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('stays silent before the delay, then whispers', () => { + render() + expect(screen.queryByRole('note')).toBeNull() + act(() => vi.advanceTimersByTime(15_100)) + expect(screen.getByRole('note')).toBeInTheDocument() + }) + + it('never appears while suppressed, and remembers the sensor ran this session', () => { + const { rerender } = render() + act(() => vi.advanceTimersByTime(16_000)) + expect(screen.queryByRole('note')).toBeNull() + // Sensor stopped again — the mode was still touched this session. + rerender() + act(() => vi.advanceTimersByTime(16_000)) + expect(screen.queryByRole('note')).toBeNull() + }) + + it('dismiss writes localStorage and hides forever', () => { + render() + act(() => vi.advanceTimersByTime(15_100)) + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByRole('note')).toBeNull() + expect(window.localStorage.getItem('qm_hint_wire')).toBe('1') + // A fresh mount respects the stored dismissal. + render() + act(() => vi.advanceTimersByTime(15_100)) + expect(screen.queryByRole('note')).toBeNull() + }) +}) diff --git a/frontend/src/components/ModeHint.tsx b/frontend/src/components/ModeHint.tsx new file mode 100644 index 0000000..51456fb --- /dev/null +++ b/frontend/src/components/ModeHint.tsx @@ -0,0 +1,76 @@ +// First-run hint for a mode panel: a single in-fiction line that appears +// after ~15s of the panel sitting idle, and only if that mode's sensor has +// never been started this session. Dismissing writes localStorage +// (`qm_hint_`) so the hint never returns on this vessel. +// +// Mounted with one line inside each mode panel; `suppressed` is the +// panel's own "the sensor is (or has been) running" signal. + +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import './ModeHint.css' + +/** How long a panel must sit untouched before the veil offers a hint. */ +const HINT_DELAY_MS = 15_000 + +// Modes whose sensor was started at least once this session — module-level +// on purpose: switching modes remounts the panels, but a hint about a +// sensor the seeker already used would read as nagging. +const startedThisSession = new Set() + +const storageKey = (mode: string) => `qm_hint_${mode}` + +function isDismissed(mode: string): boolean { + try { + return window.localStorage.getItem(storageKey(mode)) === '1' + } catch { + return false + } +} + +export function ModeHint({ mode, suppressed = false }: { mode: string; suppressed?: boolean }) { + const { t } = useTranslation() + const [ready, setReady] = useState(false) + const [dismissed, setDismissed] = useState(() => isDismissed(mode)) + + // A running sensor marks the mode as touched for the whole session. + useEffect(() => { + if (suppressed) startedThisSession.add(mode) + }, [suppressed, mode]) + + useEffect(() => { + setReady(false) + setDismissed(isDismissed(mode)) + const id = window.setTimeout(() => setReady(true), HINT_DELAY_MS) + return () => window.clearTimeout(id) + }, [mode]) + + if (dismissed || !ready || suppressed || startedThisSession.has(mode)) return null + + const dismiss = () => { + setDismissed(true) + try { + window.localStorage.setItem(storageKey(mode), '1') + } catch { + // Storage refused (private mode, quota) — the hint still dies for + // this render; it may whisper again another day. + } + } + + return ( +
+ + ✶ + + {t(`seance.hints.${mode}`)} + +
+ ) +} diff --git a/frontend/src/components/VeilConditions.css b/frontend/src/components/VeilConditions.css new file mode 100644 index 0000000..295fa5f --- /dev/null +++ b/frontend/src/components/VeilConditions.css @@ -0,0 +1,54 @@ +/* VeilConditions — compact sky/field strip in the séance side column. + Tokens follow SeancePage.css: phosphor #7cffb2 · violet #b26bff · + panel rgba(16,16,26,0.85) · border rgba(124,255,178,0.18). */ + +.veil-conditions { + padding: 0.6rem 0.7rem; + border: 1px solid rgba(124, 255, 178, 0.18); + border-radius: 4px; + background: rgba(16, 16, 26, 0.85); + font-size: 0.76rem; + line-height: 1.5; + color: #d3e9db; +} + +.veil-conditions-title { + margin: 0 0 0.35rem; + font-size: 0.68rem; + font-weight: 400; + letter-spacing: 0.18em; + color: #7cffb2; + opacity: 0.7; +} + +.veil-conditions-line { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; + flex-wrap: wrap; /* stays legible at 390px */ +} + +.veil-moon-glyph { + flex: none; + filter: drop-shadow(0 0 4px rgba(178, 107, 255, 0.5)); +} + +.veil-moon-name { + min-width: 0; +} + +.veil-thinness { + color: #b26bff; + font-style: italic; + opacity: 0.9; +} + +.veil-field-quiet { + opacity: 0.75; +} + +.veil-field-storm { + color: #b26bff; + text-shadow: 0 0 8px rgba(178, 107, 255, 0.6); +} diff --git a/frontend/src/components/VeilConditions.test.tsx b/frontend/src/components/VeilConditions.test.tsx new file mode 100644 index 0000000..5c063a8 --- /dev/null +++ b/frontend/src/components/VeilConditions.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import '../i18n' +import { VeilConditions } from './VeilConditions' + +const sky = { + moon_phase: 0.5, + moon_name: 'full moon', + moon_illumination: 0.98, + witching_proximity: null, + thinness: 0.98, +} + +function stubFetch(body: unknown, ok = true) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok, json: () => Promise.resolve(body) }), + ) +} + +describe('VeilConditions', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders moon, thinness phrase and storm line from the endpoint', async () => { + stubFetch({ sky, geomagnetic: { kp: 6.3, label: 'moderate geomagnetic storm', storm: true } }) + render() + expect(await screen.findByText('full moon')).toBeInTheDocument() + expect(screen.getByText('98% lit')).toBeInTheDocument() + expect(screen.getByText('the veil runs thin tonight')).toBeInTheDocument() + expect(screen.getByText('geomagnetic storm')).toBeInTheDocument() + expect(screen.getByText('Kp 6')).toBeInTheDocument() + }) + + it('omits the field line when geomagnetic is null', async () => { + stubFetch({ sky, geomagnetic: null }) + render() + expect(await screen.findByText('full moon')).toBeInTheDocument() + expect(screen.queryByText(/field:/)).toBeNull() + expect(screen.queryByText(/Kp/)).toBeNull() + }) + + it('renders nothing when the fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down'))) + const { container } = render() + await waitFor(() => expect(fetch).toHaveBeenCalled()) + expect(container.firstChild).toBeNull() + }) +}) diff --git a/frontend/src/components/VeilConditions.tsx b/frontend/src/components/VeilConditions.tsx new file mode 100644 index 0000000..38c0d40 --- /dev/null +++ b/frontend/src/components/VeilConditions.tsx @@ -0,0 +1,95 @@ +// Veil conditions strip for the séance side column: moon phase, veil +// thinness as a phrase, and the geomagnetic field when NOAA has spoken. +// +// Polls GET /api/conditions once on mount and every 10 minutes. Renders +// nothing while loading and has no error state on purpose — missing data +// just means fewer lines; the séance never shows a broken weather report. + +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import './VeilConditions.css' + +const POLL_MS = 10 * 60 * 1000 + +/** Backend phase name (spaces → underscores) to a moon glyph. */ +const MOON_GLYPHS: Record = { + new_moon: '🌑', + waxing_crescent: '🌒', + first_quarter: '🌓', + waxing_gibbous: '🌔', + full_moon: '🌕', + waning_gibbous: '🌖', + last_quarter: '🌗', + waning_crescent: '🌘', +} + +type Conditions = { + sky: { + moon_name: string + moon_illumination: number + thinness: number + } + geomagnetic: { kp: number; label: string; storm: boolean } | null +} + +export function VeilConditions() { + const { t } = useTranslation() + const [data, setData] = useState(null) + + useEffect(() => { + let alive = true + const load = async () => { + try { + const res = await fetch('/api/conditions', { credentials: 'include' }) + if (!res.ok) return + const body = (await res.json()) as Conditions + if (alive && body && body.sky) setData(body) + } catch { + // The sky report is a garnish — a failed fetch changes nothing. + } + } + void load() + const id = window.setInterval(() => void load(), POLL_MS) + return () => { + alive = false + window.clearInterval(id) + } + }, []) + + if (!data) return null + + const moonKey = data.sky.moon_name.replace(/ /g, '_') + const veilKey = + data.sky.thinness >= 0.66 ? 'thin' : data.sky.thinness >= 0.33 ? 'veiled' : 'heavy' + const geo = data.geomagnetic + + return ( +
+

{t('seance.conditions.title')}

+
+ + {MOON_GLYPHS[moonKey] ?? '🌑'} + + {t(`seance.conditions.moon.${moonKey}`)} + + {t('seance.conditions.lit', { + percent: Math.round(data.sky.moon_illumination * 100), + })} + +
+
+ {t(`seance.conditions.veil.${veilKey}`)} +
+ {geo && ( +
+ Kp {geo.kp.toFixed(0)} + + {geo.storm + ? t('seance.conditions.fieldStorm') + : t('seance.conditions.fieldQuiet')} + +
+ )} +
+ ) +} diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs index efd7d59..8501e0e 100644 --- a/frontend/src/i18n/coverage-check.mjs +++ b/frontend/src/i18n/coverage-check.mjs @@ -34,6 +34,12 @@ const RULES = [ ['seance.status.', ['attuning', 'summoning', 'gathering']], ['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message ['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']], + ['seance.hints.', ['wire', 'evp', 'radio', 'ouija', 'emf']], + ['seance.conditions.moon.', [ + 'new_moon', 'waxing_crescent', 'first_quarter', 'waxing_gibbous', + 'full_moon', 'waning_gibbous', 'last_quarter', 'waning_crescent', + ]], + ['seance.conditions.veil.', ['thin', 'veiled', 'heavy']], ['seance.views.', ['board', 'matrix', 'graphs']], ['seance.entity.rarity.', RARITY], ['codex.rarity.', RARITY], diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 50ae8fa..9ae6775 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -127,12 +127,40 @@ "line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB", "count": "{{count}} anomalies witnessed this session" }, + "hints": { + "wire": "the wire whispers on its own when you listen passively", + "evp": "the veil listens through your microphone — grant it your ear", + "radio": "a tuner in the hand hears further than the wire", + "ouija": "ask below, or let the board drift", + "emf": "hold still; the field remembers movement" + }, + "conditions": { + "title": "VEIL CONDITIONS", + "lit": "{{percent}}% lit", + "veil": { + "thin": "the veil runs thin tonight", + "veiled": "the veil hangs, neither open nor shut", + "heavy": "the veil lies heavy" + }, + "fieldQuiet": "field: quiet", + "fieldStorm": "geomagnetic storm", + "moon": { + "new_moon": "new moon", + "waxing_crescent": "waxing crescent", + "first_quarter": "first quarter", + "waxing_gibbous": "waxing gibbous", + "full_moon": "full moon", + "waning_gibbous": "waning gibbous", + "last_quarter": "last quarter", + "waning_crescent": "waning crescent" + } + }, "evp": { "start": "open the microphone", "stop": "close the microphone", "listening": "listening to the voice band…", "deniedTitle": "the microphone stays shut", - "denied": "This vessel refused the microphone. Grant audio permission in your browser to practice EVP — or let the Wire Ghost listen instead; it needs nothing but the network you already fear.", + "denied": "This vessel refused the microphone. Grant audio permission in your browser's site settings to practice EVP — or turn to the ouija board or the Wire Ghost instead; the board needs no ear, and the wire only the network you already fear.", "floor": "room floor", "band": "voice band 300–3400 Hz", "scopeLabel": "live audio spectrum from the microphone" @@ -141,7 +169,7 @@ "start": "sweep the band", "stop": "rest the needle", "unsupportedTitle": "this vessel cannot hear the radio dead", - "unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes do not — the wire and the microphone still answer.", + "unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes still answer — EVP hears through the microphone you already carry, and the wire needs nothing at all.", "troubleshoot": "troubleshooting: chromium-based browser · udev rules for 0bda:2838 · the dongle plugged in, not merely believed in", "insecure": "the radio dead only speak through consecrated ground — open this site via its https address.", "insecureHint": "localhost counts as hallowed; a bare http://lan-ip does not.", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index f8788d8..7020879 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -127,12 +127,40 @@ "line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB", "count": "{{count}} anomalías presenciadas en esta sesión" }, + "hints": { + "wire": "el cable susurra por sí solo cuando escuchas en pasivo", + "evp": "el velo escucha a través de tu micrófono — préstale tu oído", + "radio": "un sintonizador en la mano oye más lejos que el cable", + "ouija": "pregunta abajo, o deja que el tablero derive", + "emf": "quédate quieto; el campo recuerda el movimiento" + }, + "conditions": { + "title": "CONDICIONES DEL VELO", + "lit": "{{percent}}% iluminada", + "veil": { + "thin": "el velo se vuelve fino esta noche", + "veiled": "el velo cuelga, ni abierto ni cerrado", + "heavy": "el velo pesa" + }, + "fieldQuiet": "campo: en calma", + "fieldStorm": "tormenta geomagnética", + "moon": { + "new_moon": "luna nueva", + "waxing_crescent": "luna creciente", + "first_quarter": "cuarto creciente", + "waxing_gibbous": "gibosa creciente", + "full_moon": "luna llena", + "waning_gibbous": "gibosa menguante", + "last_quarter": "cuarto menguante", + "waning_crescent": "luna menguante" + } + }, "evp": { "start": "abrir el micrófono", "stop": "cerrar el micrófono", "listening": "escuchando la banda de voz…", "deniedTitle": "el micrófono sigue cerrado", - "denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en tu navegador para practicar EVP — o deja que el Fantasma del Cable escuche en tu lugar; no necesita más que la red que ya temes.", + "denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en los ajustes del sitio de tu navegador para practicar EVP — o acude al tablero ouija o al Fantasma del Cable; el tablero no necesita oído, y el cable solo la red que ya temes.", "floor": "suelo de la sala", "band": "banda de voz 300–3400 Hz", "scopeLabel": "espectro de audio en vivo desde el micrófono" @@ -141,7 +169,7 @@ "start": "barrer la banda", "stop": "detener la aguja", "unsupportedTitle": "este recipiente no oye a los muertos de la radio", - "unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos no — el cable y el micrófono aún responden.", + "unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos aún responden — EVP escucha por el micrófono que ya llevas, y el cable no necesita nada.", "troubleshoot": "solución de problemas: navegador basado en Chromium · reglas udev para 0bda:2838 · el dongle enchufado, no meramente invocado", "insecure": "los muertos de la radio solo hablan desde suelo consagrado — abre este sitio por su dirección https.", "insecureHint": "localhost cuenta como sagrado; una http://ip-local desnuda, no.", diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index fc7cb51..bec7b5a 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -26,6 +26,8 @@ import { EntityCard } from '../components/EntityCard' import { RitualPanel } from '../components/RitualPanel' import { JudgmentPanel } from '../components/JudgmentPanel' import { TelemetryReadout } from '../components/TelemetryReadout' +import { VeilConditions } from '../components/VeilConditions' +import { ModeHint } from '../components/ModeHint' import { PlanchetteMachine } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette' import { EvpListener } from '../lib/evp' @@ -362,6 +364,7 @@ function SeanceSession({ username }: { username: string }) { onToggleOpen={() => setDossierOpen((o) => !o)} /> + @@ -477,6 +480,7 @@ function WirePanel() { })}

)} + ) } @@ -640,6 +644,7 @@ function EvpPanel() { > {listening ? t('seance.evp.stop') : t('seance.evp.start')} + ) } @@ -869,6 +874,7 @@ function RadioPanel() { > {phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')} + ) } @@ -909,6 +915,7 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) { {snapshot && snapshot.spelled.length > 0 && (

{snapshot.spelled.slice(-6).join(' · ')}

)} + ) } @@ -1167,6 +1174,7 @@ function EmfPanel() { {t('seance.emf.calibrate', { defaultValue: 'calibrate' })} + ) }