Adds /devices — pair an ESP32 sensor node (POST /api/device), reveal its
raw pairing token exactly once with a hard-to-miss "cannot be shown again"
warning (styled like a real API-key-reveal UI), then a live dashboard
subscribing to /ws/device-feed: the initial `devices` frame seeds paired
devices, and `reading` frames update one row per distinct sensor_type in
place. sensor_type/value/unit are rendered fully generically per the
contract (free-form, open-ended) — an unrecognized sensor_type renders
safely with no special-casing.
- frontend/src/lib/deviceFeed.ts: reconnecting WS client for
/ws/device-feed, mirroring VeilSocket's backoff shape (receive-only, no
outbox needed).
- frontend/src/pages/DevicesPage.{tsx,css}: pairing form + one-time token
reveal + live device-card grid. Leans into "hacker" terminal styling
(monospace readouts, terminal device cards) over the app's usual gothic
chrome, per the design spec, while keeping the existing dark/violet
palette tokens from App.css.
- Route + nav link wired into App.tsx / SeancePage.tsx.
- i18n: new `devices.*` / `nav.devices` keys in en.json + es.json; added a
coverage-check.mjs domain rule for the dynamic connection-state key,
mirroring the existing `seance.connection.` rule.
Tests: deviceFeed.test.ts (backoff/reconnect/frame delivery) and
DevicesPage.test.tsx (empty state, name validation, one-time token reveal
and dismissal, live frame updates in place without duplicating rows,
multi-device/multi-sensor rendering, and a mocked unrecognized sensor_type
that must not crash). Full suite: 154 passed (137 pre-existing + 17 new).
`npx tsc -b` and `npm run build` both clean.
Assumption (undocumented in spec): POST /api/device's JSON response shape
is inferred as `{id, name, token, last_seen_at}` since the Contract section
only describes the endpoint in prose. GET /api/device is intentionally not
called — the live dashboard is fully seeded by /ws/device-feed's initial
`devices` frame per the contract, so it's redundant for this page's scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>