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:
Indiana
2026-07-28 17:31:10 +00:00
parent 2e2c64d36b
commit eaa28b20e8
13 changed files with 579 additions and 4 deletions

View File

@@ -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)}
/>
<VeilConditions />
<RitualPanel />
<JudgmentPanel />
@@ -477,6 +480,7 @@ function WirePanel() {
})}
</p>
)}
<ModeHint mode="wire" suppressed={state.passive} />
</div>
)
}
@@ -640,6 +644,7 @@ function EvpPanel() {
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
</button>
<ModeHint mode="evp" suppressed={listening || pending} />
</div>
)
}
@@ -869,6 +874,7 @@ function RadioPanel() {
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
</button>
<ModeHint mode="radio" suppressed={phase !== 'idle'} />
</div>
)
}
@@ -909,6 +915,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>
)
}
@@ -1167,6 +1174,7 @@ function EmfPanel() {
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
</button>
</div>
<ModeHint mode="emf" suppressed={listening || pending} />
</div>
)
}