feat: legibility — conditions, hints, kinder errors (Workstream B)

GET /api/conditions surfaces what the backend already computes: moon
phase, veil thinness, geomagnetic state — junk lon degrades to moon-only
instead of a 422, missing NOAA data means fewer lines, never an error.
VeilConditions renders it in the séance side column, polling every 10 min.

ModeHint: one in-fiction line per mode after 15s of an unused sensor,
dismissed forever via localStorage. Error copy in evp/radio now
detect-and-redirects (mic denied -> 'the board needs no ear'; no WebUSB
-> try EVP) instead of dead-ending.

No geolocation prompt from the conditions strip — asking for location
from a passive readout would be hostile; ?lon= stays supported for
callers that have it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	frontend/src/pages/SeancePage.tsx
This commit is contained in:
Indiana
2026-07-28 19:19:44 +00:00
13 changed files with 579 additions and 4 deletions

View File

@@ -27,6 +27,8 @@ import { RitualPanel } from '../components/RitualPanel'
import { DeviceWhisper } from '../components/DeviceWhisper'
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'
@@ -387,6 +389,7 @@ function SeanceSession({ username }: { username: string }) {
onToggleOpen={() => setDossierOpen((o) => !o)}
/>
<VeilConditions />
<DeviceWhisper />
<RitualPanel />
<JudgmentPanel />
@@ -503,6 +506,7 @@ function WirePanel() {
})}
</p>
)}
<ModeHint mode="wire" suppressed={state.passive} />
</div>
)
}
@@ -666,6 +670,7 @@ function EvpPanel() {
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
</button>
<ModeHint mode="evp" suppressed={listening || pending} />
</div>
)
}
@@ -895,6 +900,7 @@ function RadioPanel() {
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
</button>
<ModeHint mode="radio" suppressed={phase !== 'idle'} />
</div>
)
}
@@ -935,6 +941,7 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
{snapshot && snapshot.spelled.length > 0 && (
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
)}
<ModeHint mode="ouija" suppressed={word !== null} />
</div>
)
}
@@ -1193,6 +1200,7 @@ function EmfPanel() {
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
</button>
</div>
<ModeHint mode="emf" suppressed={listening || pending} />
</div>
)
}