feat: legibility wave — veil conditions, first-run hints, kinder errors
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_<mode>). 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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from app.config import settings
|
|||||||
from app.db import Base, async_session_maker, engine
|
from app.db import Base, async_session_maker, engine
|
||||||
from app.routes.auth import router as auth_router
|
from app.routes.auth import router as auth_router
|
||||||
from app.routes.codex import router as codex_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.device import router as device_router
|
||||||
from app.routes.inventory import router as inventory_router
|
from app.routes.inventory import router as inventory_router
|
||||||
from app.routes.shop import router as shop_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 = FastAPI(title="Quantumancy", lifespan=lifespan)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(codex_router)
|
app.include_router(codex_router)
|
||||||
|
app.include_router(conditions_router)
|
||||||
app.include_router(device_router)
|
app.include_router(device_router)
|
||||||
app.include_router(inventory_router)
|
app.include_router(inventory_router)
|
||||||
app.include_router(shop_router)
|
app.include_router(shop_router)
|
||||||
|
|||||||
46
backend/app/routes/conditions.py
Normal file
46
backend/app/routes/conditions.py
Normal file
@@ -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(),
|
||||||
|
}
|
||||||
73
backend/tests/test_conditions.py
Normal file
73
backend/tests/test_conditions.py
Normal file
@@ -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"
|
||||||
66
frontend/src/components/ModeHint.css
Normal file
66
frontend/src/components/ModeHint.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/components/ModeHint.test.tsx
Normal file
43
frontend/src/components/ModeHint.test.tsx
Normal file
@@ -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(<ModeHint mode="ouija" />)
|
||||||
|
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(<ModeHint mode="evp" suppressed />)
|
||||||
|
act(() => vi.advanceTimersByTime(16_000))
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
// Sensor stopped again — the mode was still touched this session.
|
||||||
|
rerender(<ModeHint mode="evp" suppressed={false} />)
|
||||||
|
act(() => vi.advanceTimersByTime(16_000))
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismiss writes localStorage and hides forever', () => {
|
||||||
|
render(<ModeHint mode="wire" />)
|
||||||
|
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(<ModeHint mode="wire" />)
|
||||||
|
act(() => vi.advanceTimersByTime(15_100))
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
76
frontend/src/components/ModeHint.tsx
Normal file
76
frontend/src/components/ModeHint.tsx
Normal file
@@ -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_<mode>`) 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<string>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mode-hint" role="note">
|
||||||
|
<span className="mode-hint-glyph" aria-hidden>
|
||||||
|
✶
|
||||||
|
</span>
|
||||||
|
<span className="mode-hint-text">{t(`seance.hints.${mode}`)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mode-hint-dismiss"
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label={t('common.dismiss')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
frontend/src/components/VeilConditions.css
Normal file
54
frontend/src/components/VeilConditions.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
50
frontend/src/components/VeilConditions.test.tsx
Normal file
50
frontend/src/components/VeilConditions.test.tsx
Normal file
@@ -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(<VeilConditions />)
|
||||||
|
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(<VeilConditions />)
|
||||||
|
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(<VeilConditions />)
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalled())
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
95
frontend/src/components/VeilConditions.tsx
Normal file
95
frontend/src/components/VeilConditions.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<Conditions | null>(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 (
|
||||||
|
<section className="veil-conditions" aria-label={t('seance.conditions.title')}>
|
||||||
|
<h3 className="veil-conditions-title">{t('seance.conditions.title')}</h3>
|
||||||
|
<div className="veil-conditions-line">
|
||||||
|
<span className="veil-moon-glyph" aria-hidden>
|
||||||
|
{MOON_GLYPHS[moonKey] ?? '🌑'}
|
||||||
|
</span>
|
||||||
|
<span className="veil-moon-name">{t(`seance.conditions.moon.${moonKey}`)}</span>
|
||||||
|
<span className="dim">
|
||||||
|
{t('seance.conditions.lit', {
|
||||||
|
percent: Math.round(data.sky.moon_illumination * 100),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="veil-conditions-line veil-thinness">
|
||||||
|
{t(`seance.conditions.veil.${veilKey}`)}
|
||||||
|
</div>
|
||||||
|
{geo && (
|
||||||
|
<div className="veil-conditions-line">
|
||||||
|
<span className="dim">Kp {geo.kp.toFixed(0)}</span>
|
||||||
|
<span className={geo.storm ? 'veil-field-storm' : 'veil-field-quiet'}>
|
||||||
|
{geo.storm
|
||||||
|
? t('seance.conditions.fieldStorm')
|
||||||
|
: t('seance.conditions.fieldQuiet')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ const RULES = [
|
|||||||
['seance.status.', ['attuning', 'summoning', 'gathering']],
|
['seance.status.', ['attuning', 'summoning', 'gathering']],
|
||||||
['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message
|
['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message
|
||||||
['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']],
|
['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.views.', ['board', 'matrix', 'graphs']],
|
||||||
['seance.entity.rarity.', RARITY],
|
['seance.entity.rarity.', RARITY],
|
||||||
['codex.rarity.', RARITY],
|
['codex.rarity.', RARITY],
|
||||||
|
|||||||
@@ -127,12 +127,40 @@
|
|||||||
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
|
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
|
||||||
"count": "{{count}} anomalies witnessed this session"
|
"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": {
|
"evp": {
|
||||||
"start": "open the microphone",
|
"start": "open the microphone",
|
||||||
"stop": "close the microphone",
|
"stop": "close the microphone",
|
||||||
"listening": "listening to the voice band…",
|
"listening": "listening to the voice band…",
|
||||||
"deniedTitle": "the microphone stays shut",
|
"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",
|
"floor": "room floor",
|
||||||
"band": "voice band 300–3400 Hz",
|
"band": "voice band 300–3400 Hz",
|
||||||
"scopeLabel": "live audio spectrum from the microphone"
|
"scopeLabel": "live audio spectrum from the microphone"
|
||||||
@@ -141,7 +169,7 @@
|
|||||||
"start": "sweep the band",
|
"start": "sweep the band",
|
||||||
"stop": "rest the needle",
|
"stop": "rest the needle",
|
||||||
"unsupportedTitle": "this vessel cannot hear the radio dead",
|
"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",
|
"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.",
|
"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.",
|
"insecureHint": "localhost counts as hallowed; a bare http://lan-ip does not.",
|
||||||
|
|||||||
@@ -127,12 +127,40 @@
|
|||||||
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
|
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
|
||||||
"count": "{{count}} anomalías presenciadas en esta sesión"
|
"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": {
|
"evp": {
|
||||||
"start": "abrir el micrófono",
|
"start": "abrir el micrófono",
|
||||||
"stop": "cerrar el micrófono",
|
"stop": "cerrar el micrófono",
|
||||||
"listening": "escuchando la banda de voz…",
|
"listening": "escuchando la banda de voz…",
|
||||||
"deniedTitle": "el micrófono sigue cerrado",
|
"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",
|
"floor": "suelo de la sala",
|
||||||
"band": "banda de voz 300–3400 Hz",
|
"band": "banda de voz 300–3400 Hz",
|
||||||
"scopeLabel": "espectro de audio en vivo desde el micrófono"
|
"scopeLabel": "espectro de audio en vivo desde el micrófono"
|
||||||
@@ -141,7 +169,7 @@
|
|||||||
"start": "barrer la banda",
|
"start": "barrer la banda",
|
||||||
"stop": "detener la aguja",
|
"stop": "detener la aguja",
|
||||||
"unsupportedTitle": "este recipiente no oye a los muertos de la radio",
|
"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",
|
"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.",
|
"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.",
|
"insecureHint": "localhost cuenta como sagrado; una http://ip-local desnuda, no.",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { EntityCard } from '../components/EntityCard'
|
|||||||
import { RitualPanel } from '../components/RitualPanel'
|
import { RitualPanel } from '../components/RitualPanel'
|
||||||
import { JudgmentPanel } from '../components/JudgmentPanel'
|
import { JudgmentPanel } from '../components/JudgmentPanel'
|
||||||
import { TelemetryReadout } from '../components/TelemetryReadout'
|
import { TelemetryReadout } from '../components/TelemetryReadout'
|
||||||
|
import { VeilConditions } from '../components/VeilConditions'
|
||||||
|
import { ModeHint } from '../components/ModeHint'
|
||||||
import { PlanchetteMachine } from '../lib/planchette'
|
import { PlanchetteMachine } from '../lib/planchette'
|
||||||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||||||
import { EvpListener } from '../lib/evp'
|
import { EvpListener } from '../lib/evp'
|
||||||
@@ -362,6 +364,7 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
onToggleOpen={() => setDossierOpen((o) => !o)}
|
onToggleOpen={() => setDossierOpen((o) => !o)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<VeilConditions />
|
||||||
<RitualPanel />
|
<RitualPanel />
|
||||||
<JudgmentPanel />
|
<JudgmentPanel />
|
||||||
|
|
||||||
@@ -477,6 +480,7 @@ function WirePanel() {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<ModeHint mode="wire" suppressed={state.passive} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -640,6 +644,7 @@ function EvpPanel() {
|
|||||||
>
|
>
|
||||||
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
|
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
|
||||||
</button>
|
</button>
|
||||||
|
<ModeHint mode="evp" suppressed={listening || pending} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -869,6 +874,7 @@ function RadioPanel() {
|
|||||||
>
|
>
|
||||||
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
|
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
|
||||||
</button>
|
</button>
|
||||||
|
<ModeHint mode="radio" suppressed={phase !== 'idle'} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -909,6 +915,7 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
|
|||||||
{snapshot && snapshot.spelled.length > 0 && (
|
{snapshot && snapshot.spelled.length > 0 && (
|
||||||
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
|
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
|
||||||
)}
|
)}
|
||||||
|
<ModeHint mode="ouija" suppressed={word !== null} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1167,6 +1174,7 @@ function EmfPanel() {
|
|||||||
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
|
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<ModeHint mode="emf" suppressed={listening || pending} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user