Merge Workstream H: device pairing UI + live dashboard

Resolved conflicts in App.tsx/SeancePage.tsx (both F and H added new
routes/nav links, kept both) and i18n files (both added sibling top-level
keys — inventory + devices, fixed nesting after conflict markers removed).

Also fixed a real integration gap: DevicesPage.test.tsx's mock User object
predated Workstream F's unlocks/essence additions to the User type (the
two workstreams built in parallel isolation and couldn't see each other's
changes). 269/269 frontend tests pass, tsc clean.
This commit is contained in:
Indiana
2026-07-24 20:47:57 +00:00
10 changed files with 1492 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import { HauntingLayer } from './components/HauntingLayer'
import { GhostLog } from './components/GhostLog'
import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage'
import { DevicesPage } from './pages/DevicesPage'
import { EnterPage } from './pages/EnterPage'
import { InventoryPage } from './pages/InventoryPage'
import { LandingPage } from './pages/LandingPage'
@@ -31,6 +32,7 @@ function App() {
<Route path="/codex/:id" element={<CodexEntityPage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="*" element={<LandingPage />} />
</Routes>
</BrowserRouter>

View File

@@ -30,6 +30,7 @@ for (const f of files) {
const RARITY = ['common', 'uncommon', 'rare', 'mythic']
const RULES = [
['seance.connection.', ['connecting', 'open', 'unstable', 'closed']],
['devices.dashboard.connection.', ['connecting', 'open', 'unstable', 'closed']],
['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']],

View File

@@ -14,6 +14,7 @@
"codex": "codex",
"shop": "shop",
"inventory": "inventory",
"devices": "devices",
"seance": "séance",
"enter": "enter",
"logout": "depart"
@@ -443,6 +444,39 @@
"error": "the sigil would not hold"
}
},
"devices": {
"back": "← the threshold",
"title": "DEVICE BAY",
"subtitle": "pair hardware to your account and watch its telemetry live",
"pair": {
"title": "PAIR A NEW DEVICE",
"nameLabel": "device name",
"namePlaceholder": "e.g. attic-node-01",
"submit": "pair device",
"pending": "pairing…",
"nameRequired": "name the device before pairing",
"error": "the pairing handshake failed — try again"
},
"token": {
"warning": "copy this now — it cannot be shown again. losing it means re-pairing the device.",
"copy": "copy",
"copied": "copied",
"confirm": "I've saved it — hide token"
},
"dashboard": {
"title": "LIVE FEED",
"empty": "no devices paired yet — pair one above to see it here",
"lastSeen": "last seen",
"lastSeenNever": "never",
"noReadings": "awaiting first reading…",
"connection": {
"connecting": "reaching the feed…",
"open": "feed live",
"unstable": "feed unstable…",
"closed": "feed offline"
}
}
},
"haunting": {
"whisper": {
"behindYou": "behind you",

View File

@@ -14,6 +14,7 @@
"codex": "códice",
"shop": "relicario",
"inventory": "inventario",
"devices": "dispositivos",
"seance": "sesión",
"enter": "entrar",
"logout": "partir"
@@ -443,6 +444,39 @@
"error": "el sigilo no se sostuvo"
}
},
"devices": {
"back": "← el umbral",
"title": "BAHÍA DE DISPOSITIVOS",
"subtitle": "empareja hardware con tu cuenta y observa su telemetría en vivo",
"pair": {
"title": "EMPAREJAR UN NUEVO DISPOSITIVO",
"nameLabel": "nombre del dispositivo",
"namePlaceholder": "p. ej. nodo-ático-01",
"submit": "emparejar dispositivo",
"pending": "emparejando…",
"nameRequired": "nombra el dispositivo antes de emparejarlo",
"error": "el emparejamiento falló — inténtalo de nuevo"
},
"token": {
"warning": "cópialo ahora — no volverá a mostrarse. perderlo significa volver a emparejar el dispositivo.",
"copy": "copiar",
"copied": "copiado",
"confirm": "ya lo guardé — ocultar token"
},
"dashboard": {
"title": "TRANSMISIÓN EN VIVO",
"empty": "aún no hay dispositivos emparejados — empareja uno arriba para verlo aquí",
"lastSeen": "visto por última vez",
"lastSeenNever": "nunca",
"noReadings": "esperando la primera lectura…",
"connection": {
"connecting": "alcanzando la transmisión…",
"open": "transmisión en vivo",
"unstable": "transmisión inestable…",
"closed": "transmisión desconectada"
}
}
},
"haunting": {
"whisper": {
"behindYou": "detrás de ti",

View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DeviceFeedSocket } from './deviceFeed'
import type { DeviceFeedConnectionState, DeviceFeedFrame } from './deviceFeed'
const URL = 'ws://quantumancy.test/ws/device-feed'
/** Minimal stand-in for the DOM WebSocket, driven manually by the tests. */
class FakeWebSocket {
readyState = 0 // CONNECTING
onopen: (() => void) | null = null
onmessage: ((ev: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
constructor(readonly url: string) {}
close(): void {
this.readyState = 3 // CLOSED
this.onclose?.()
}
// ---- test drives ----
serverOpen(): void {
this.readyState = 1 // WebSocket.OPEN
this.onopen?.()
}
serverSend(data: unknown): void {
this.onmessage?.({ data })
}
serverClose(): void {
this.readyState = 3
this.onclose?.()
}
}
function makeHarness(opts: { baseBackoffMs?: number; maxBackoffMs?: number } = {}) {
const sockets: FakeWebSocket[] = []
const socket = new DeviceFeedSocket({
url: URL,
baseBackoffMs: opts.baseBackoffMs ?? 100,
maxBackoffMs: opts.maxBackoffMs ?? 1000,
socketFactory: (url: string): WebSocket => {
const fake = new FakeWebSocket(url)
sockets.push(fake)
return fake as unknown as WebSocket
},
})
return { socket, sockets }
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('DeviceFeedSocket', () => {
it('starts closed and transitions through connecting -> open', () => {
const { socket, sockets } = makeHarness()
const states: DeviceFeedConnectionState[] = []
socket.onState((s) => states.push(s))
expect(socket.state).toBe('closed')
socket.connect()
expect(socket.state).toBe('connecting')
sockets[0].serverOpen()
expect(socket.state).toBe('open')
expect(states).toEqual(['connecting', 'open'])
})
it('delivers parsed frames to handlers', () => {
const { socket, sockets } = makeHarness()
const frames: DeviceFeedFrame[] = []
socket.onFrame((f) => frames.push(f))
socket.connect()
sockets[0].serverOpen()
sockets[0].serverSend(
JSON.stringify({
type: 'devices',
devices: [{ id: 'd1', name: 'attic-node', last_seen_at: null }],
}),
)
sockets[0].serverSend(
JSON.stringify({
type: 'reading',
device_id: 'd1',
sensor_type: 'temperature',
value: 21.4,
unit: 'c',
metadata: {},
at: '2026-07-23T00:00:00Z',
}),
)
expect(frames).toHaveLength(2)
expect(frames[0]).toMatchObject({ type: 'devices' })
expect(frames[1]).toMatchObject({ type: 'reading', sensor_type: 'temperature' })
})
it('ignores malformed JSON without throwing', () => {
const { socket, sockets } = makeHarness()
const frames: DeviceFeedFrame[] = []
socket.onFrame((f) => frames.push(f))
socket.connect()
sockets[0].serverOpen()
expect(() => sockets[0].serverSend('{not json')).not.toThrow()
expect(frames).toHaveLength(0)
})
it('reconnects with backoff after an unexpected close, and delivers frames on the new socket', () => {
const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 1000 })
const states: DeviceFeedConnectionState[] = []
socket.onState((s) => states.push(s))
socket.connect()
sockets[0].serverOpen()
sockets[0].serverClose()
expect(socket.state).toBe('unstable')
vi.advanceTimersByTime(100)
expect(sockets).toHaveLength(2)
sockets[1].serverOpen()
expect(socket.state).toBe('open')
expect(states).toEqual(['connecting', 'open', 'unstable', 'connecting', 'open'])
})
it('does not reconnect after a deliberate close()', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
socket.close()
expect(socket.state).toBe('closed')
vi.advanceTimersByTime(5000)
expect(sockets).toHaveLength(1)
})
it('unsubscribes handlers returned by onFrame/onState', () => {
const { socket, sockets } = makeHarness()
const frames: DeviceFeedFrame[] = []
const unsub = socket.onFrame((f) => frames.push(f))
socket.connect()
sockets[0].serverOpen()
unsub()
sockets[0].serverSend(JSON.stringify({ type: 'devices', devices: [] }))
expect(frames).toHaveLength(0)
})
})

View File

@@ -0,0 +1,156 @@
// Reconnecting WebSocket client for /ws/device-feed — the live instrument
// panel's data source (paired-device list + live sensor readings).
// Structurally mirrors VeilSocket (./ws.ts): same backoff/reconnect shape,
// but this feed is receive-only (the dashboard never sends client frames),
// so there's no outbox to flush.
export type DeviceSummary = {
id: string
name: string
last_seen_at: string | null
}
/** `value` is intentionally untyped: sensor_type is free-form by design, so
* a reading's value/unit shape is whatever the paired hardware sent — the
* frontend must render it generically rather than assume a shape. */
export type DeviceReading = {
device_id: string
sensor_type: string
value: unknown
unit: string
metadata: Record<string, unknown>
at: string
}
export type DeviceFeedFrame =
| { type: 'devices'; devices: DeviceSummary[] }
| ({ type: 'reading' } & DeviceReading)
export type DeviceFeedConnectionState = 'connecting' | 'open' | 'unstable' | 'closed'
type FrameHandler = (frame: DeviceFeedFrame) => void
type StateHandler = (state: DeviceFeedConnectionState) => void
export type DeviceFeedSocketOptions = {
url?: string
/** Base backoff delay (ms); doubles each retry up to maxBackoffMs. */
baseBackoffMs?: number
maxBackoffMs?: number
/** Injectable WebSocket constructor (tests). */
socketFactory?: (url: string) => WebSocket
}
export function defaultDeviceFeedUrl(): string {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
return `${proto}${location.host}/ws/device-feed`
}
export class DeviceFeedSocket {
private ws: WebSocket | null = null
private frameHandlers = new Set<FrameHandler>()
private stateHandlers = new Set<StateHandler>()
private readonly url: string
private readonly baseBackoffMs: number
private readonly maxBackoffMs: number
private readonly socketFactory: (url: string) => WebSocket
private attempts = 0
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private deliberatelyClosed = false
private _state: DeviceFeedConnectionState = 'closed'
constructor(opts: DeviceFeedSocketOptions = {}) {
this.url = opts.url ?? defaultDeviceFeedUrl()
this.baseBackoffMs = opts.baseBackoffMs ?? 800
this.maxBackoffMs = opts.maxBackoffMs ?? 15000
this.socketFactory = opts.socketFactory ?? ((url: string) => new WebSocket(url))
}
get state(): DeviceFeedConnectionState {
return this._state
}
onFrame(handler: FrameHandler): () => void {
this.frameHandlers.add(handler)
return () => this.frameHandlers.delete(handler)
}
onState(handler: StateHandler): () => void {
this.stateHandlers.add(handler)
return () => this.stateHandlers.delete(handler)
}
connect(): void {
this.deliberatelyClosed = false
this.openSocket()
}
close(): void {
this.deliberatelyClosed = true
this.clearTimer()
this.ws?.close()
this.ws = null
this.setState('closed')
}
private setState(state: DeviceFeedConnectionState): void {
if (this._state === state) return
this._state = state
this.stateHandlers.forEach((h) => h(state))
}
private clearTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
private openSocket(): void {
this.clearTimer()
this.setState('connecting')
let ws: WebSocket
try {
ws = this.socketFactory(this.url)
} catch {
this.scheduleReconnect()
return
}
this.ws = ws
ws.onopen = () => {
this.attempts = 0
this.setState('open')
}
ws.onmessage = (ev: MessageEvent) => {
if (typeof ev.data !== 'string') return
let frame: DeviceFeedFrame
try {
frame = JSON.parse(ev.data) as DeviceFeedFrame
} catch {
return // ignore malformed frames
}
this.frameHandlers.forEach((h) => h(frame))
}
ws.onclose = () => {
this.clearTimer()
this.ws = null
if (!this.deliberatelyClosed) this.scheduleReconnect()
else this.setState('closed')
}
ws.onerror = () => {
// onclose follows onerror; nothing extra to do here.
}
}
private scheduleReconnect(): void {
this.setState('unstable')
const delay = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** this.attempts)
this.attempts++
this.reconnectTimer = setTimeout(() => {
if (!this.deliberatelyClosed) this.openSocket()
}, delay)
}
}

View File

@@ -0,0 +1,446 @@
/* Device Bay — the most overtly "hacker" surface in the app: a live
instrument panel, not an occult one. Terminal readouts over the
established dark/violet palette (bg #07070d, phosphor #7cffb2, violet
#b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18) — see App.css
:root tokens). Leans harder into raw monospace/CRT texture than the
gothic serif chrome used elsewhere (ShopPage/CodexPage). */
.devices-page {
min-height: 100vh;
width: 100%;
background:
linear-gradient(180deg, rgba(124, 255, 178, 0.04), transparent 12%),
#07070d;
color: #d6e4dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
box-sizing: border-box;
position: relative;
}
.devices-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
background: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.02) 0px,
rgba(255, 255, 255, 0.02) 1px,
transparent 1px,
transparent 3px
);
}
.devices-page .visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.devices-loading {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #07070d;
color: #7cffb2;
font-family: 'IBM Plex Mono', monospace;
}
/* ---- header ---- */
.devices-header {
max-width: 900px;
margin: 0 auto 2.4rem;
}
.devices-back {
display: inline-block;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
margin-bottom: 1.2rem;
transition: color 160ms ease, text-shadow 160ms ease;
}
.devices-back:hover,
.devices-back:focus-visible {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
}
.devices-title {
font-family: 'IBM Plex Mono', monospace;
font-weight: 700;
font-size: clamp(1.6rem, 4.4vw, 2.4rem);
letter-spacing: 0.14em;
margin: 0 0 0.5rem;
color: #eafff3;
text-shadow: 0 0 16px rgba(124, 255, 178, 0.35);
}
.devices-title::before {
content: '> ';
color: #7cffb2;
}
.devices-subtitle {
margin: 0;
font-size: 0.82rem;
color: rgba(216, 230, 220, 0.6);
}
/* ---- shared section chrome ---- */
.devices-pair,
.devices-dashboard {
max-width: 900px;
margin: 0 auto 2.4rem;
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 4px;
background: rgba(16, 16, 26, 0.65);
padding: 1.4rem clamp(1rem, 3vw, 1.8rem);
}
.devices-section-title {
margin: 0 0 1rem;
font-size: 0.78rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.85);
}
.devices-state {
margin: 0;
font-size: 0.82rem;
color: rgba(216, 230, 220, 0.55);
}
/* ---- pairing form ---- */
.pair-form {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.pair-input {
flex: 1 1 240px;
background: rgba(7, 7, 13, 0.8);
border: 1px solid rgba(124, 255, 178, 0.3);
border-radius: 3px;
color: #d6e4dc;
font-family: inherit;
font-size: 0.85rem;
padding: 0.6rem 0.8rem;
}
.pair-input::placeholder {
color: rgba(216, 230, 220, 0.35);
}
.pair-input:focus-visible {
outline: none;
border-color: #7cffb2;
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.2);
}
.pair-submit {
background: rgba(124, 255, 178, 0.1);
border: 1px solid rgba(124, 255, 178, 0.5);
border-radius: 3px;
color: #7cffb2;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 0.6rem 1.1rem;
cursor: pointer;
transition: box-shadow 150ms ease, background 150ms ease;
}
.pair-submit:hover:not(:disabled) {
background: rgba(124, 255, 178, 0.18);
box-shadow: 0 0 14px rgba(124, 255, 178, 0.3);
}
.pair-submit:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pair-message {
margin: 0.7rem 0 0;
font-size: 0.78rem;
}
.pair-message.is-error {
color: #ff3b5c;
}
/* ---- one-time token reveal: styled deliberately like a real API-key-reveal
UI — impossible to miss, impossible to mistake for a dismissible toast. */
.token-reveal {
margin-top: 1.1rem;
border: 1px solid rgba(255, 59, 92, 0.55);
border-radius: 4px;
background: rgba(255, 59, 92, 0.06);
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
animation: token-pulse 2.4s ease-in-out infinite;
}
@keyframes token-pulse {
0%, 100% { box-shadow: 0 0 0 rgba(255, 59, 92, 0); }
50% { box-shadow: 0 0 22px rgba(255, 59, 92, 0.22); }
}
.token-reveal-warning {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #ffb3c0;
text-transform: uppercase;
}
.token-reveal-icon {
color: #ff3b5c;
font-size: 1rem;
}
.token-reveal-box {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
background: rgba(7, 7, 13, 0.85);
border: 1px dashed rgba(255, 59, 92, 0.4);
border-radius: 3px;
padding: 0.7rem 0.9rem;
}
.token-reveal-value {
flex: 1 1 auto;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.86rem;
word-break: break-all;
color: #7cffb2;
text-shadow: 0 0 8px rgba(124, 255, 178, 0.4);
}
.token-copy {
background: rgba(178, 107, 255, 0.12);
border: 1px solid rgba(178, 107, 255, 0.5);
border-radius: 3px;
color: #b26bff;
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.4rem 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.token-copy:hover {
background: rgba(178, 107, 255, 0.22);
}
.token-confirm {
align-self: flex-start;
background: transparent;
border: 1px solid rgba(255, 59, 92, 0.5);
border-radius: 3px;
color: #ff9caa;
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.45rem 0.9rem;
cursor: pointer;
}
.token-confirm:hover {
background: rgba(255, 59, 92, 0.1);
}
/* ---- live dashboard ---- */
.dashboard-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
margin-bottom: 1rem;
}
.dashboard-heading .devices-section-title {
margin: 0;
}
.feed-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #4a5058;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4);
flex: none;
}
.feed-connecting {
background: #b26bff;
box-shadow: 0 0 8px rgba(178, 107, 255, 0.7);
animation: feed-blink 1s steps(2, start) infinite;
}
.feed-open {
background: #7cffb2;
box-shadow: 0 0 8px rgba(124, 255, 178, 0.7);
}
.feed-unstable {
background: #ffb347;
box-shadow: 0 0 8px rgba(255, 179, 71, 0.6);
animation: feed-blink 0.6s steps(2, start) infinite;
}
.feed-closed {
background: #ff3b5c;
box-shadow: 0 0 8px rgba(255, 59, 92, 0.5);
}
@keyframes feed-blink {
50% { opacity: 0.25; }
}
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.9rem;
}
.device-card {
border: 1px solid rgba(124, 255, 178, 0.22);
border-radius: 4px;
background: rgba(7, 7, 13, 0.7);
padding: 0.85rem 0.95rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
position: relative;
}
.device-card::before {
content: '';
position: absolute;
inset: 0;
border: 1px solid rgba(124, 255, 178, 0.08);
border-radius: 4px;
pointer-events: none;
}
.device-card-head {
display: flex;
flex-direction: column;
gap: 0.15rem;
border-bottom: 1px dotted rgba(124, 255, 178, 0.18);
padding-bottom: 0.5rem;
}
.device-name {
margin: 0;
font-size: 0.92rem;
letter-spacing: 0.04em;
color: #eafff3;
}
.device-name::before {
content: '▣ ';
color: #b26bff;
}
.device-lastseen {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.04em;
color: rgba(216, 230, 220, 0.5);
text-transform: uppercase;
}
.device-noreadings {
margin: 0;
font-size: 0.76rem;
color: rgba(216, 230, 220, 0.4);
font-style: italic;
}
.sensor-grid {
display: flex;
flex-direction: column;
gap: 0.32rem;
}
.sensor-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.7rem;
border-bottom: 1px dotted rgba(124, 255, 178, 0.12);
padding-bottom: 0.24rem;
}
.sensor-type {
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: lowercase;
color: rgba(211, 233, 219, 0.6);
}
.sensor-value {
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
color: #7cffb2;
text-shadow: 0 0 8px rgba(124, 255, 178, 0.45);
}
.sensor-unit {
font-size: 0.68rem;
color: rgba(124, 255, 178, 0.55);
}
.flicker-soft {
animation: devices-flicker-soft 4.5s linear infinite;
}
@keyframes devices-flicker-soft {
0%, 100% { opacity: 1; }
95% { opacity: 1; }
96% { opacity: 0.82; }
97% { opacity: 1; }
}
@media (max-width: 520px) {
.pair-form {
flex-direction: column;
align-items: stretch;
}
.device-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,311 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { AuthProvider } from '../state/auth'
import * as api from '../api'
import type { DeviceFeedFrame } from '../lib/deviceFeed'
vi.mock('../api')
// ---- fake DeviceFeedSocket: mirrors the FakeWebSocket-driven pattern used
// in lib/ws.test.ts, but at the DeviceFeedSocket level so component tests
// can push frames directly without a real WebSocket. ----
type FrameHandler = (f: DeviceFeedFrame) => void
type StateHandler = (s: string) => void
class FakeDeviceFeedSocket {
frameHandlers: FrameHandler[] = []
stateHandlers: StateHandler[] = []
closed = false
constructor() {
instances.push(this)
}
onFrame(h: FrameHandler) {
this.frameHandlers.push(h)
return () => {
this.frameHandlers = this.frameHandlers.filter((x) => x !== h)
}
}
onState(h: StateHandler) {
this.stateHandlers.push(h)
return () => {
this.stateHandlers = this.stateHandlers.filter((x) => x !== h)
}
}
connect() {
this.stateHandlers.forEach((h) => h('open'))
}
close() {
this.closed = true
}
emit(frame: DeviceFeedFrame) {
this.frameHandlers.forEach((h) => h(frame))
}
}
let instances: FakeDeviceFeedSocket[] = []
vi.mock('../lib/deviceFeed', async () => {
const actual = await vi.importActual<typeof import('../lib/deviceFeed')>('../lib/deviceFeed')
return { ...actual, DeviceFeedSocket: FakeDeviceFeedSocket }
})
// Imported after the mocks above so the module picks up the faked socket.
const { DevicesPage, formatSensorValue } = await import('./DevicesPage')
function mockFetch(handler: (input: RequestInfo | URL) => Response | Promise<Response>) {
vi.stubGlobal('fetch', vi.fn(handler))
}
beforeEach(() => {
instances = []
vi.mocked(api.me).mockResolvedValue({ id: 'u-1', username: 'seeker', unlocks: [], essence: 0 })
})
afterEach(() => {
vi.unstubAllGlobals()
})
async function renderPage(): Promise<FakeDeviceFeedSocket> {
render(
<AuthProvider>
<MemoryRouter initialEntries={['/devices']}>
<DevicesPage />
</MemoryRouter>
</AuthProvider>,
)
await waitFor(() => expect(screen.getByText('DEVICE BAY')).toBeTruthy())
await waitFor(() => expect(instances).toHaveLength(1))
return instances[0]
}
describe('DevicesPage — pairing', () => {
beforeEach(() => {
mockFetch(async () => new Response('{}', { status: 200 }))
})
it('shows the empty dashboard state before any device is paired', async () => {
await renderPage()
expect(screen.getByText(/no devices paired yet/i)).toBeTruthy()
})
it('blocks submission and shows a validation message when the name is empty', async () => {
await renderPage()
await userEvent.click(screen.getByRole('button', { name: /pair device/i }))
expect(screen.getByText(/name the device/i)).toBeTruthy()
// No pairing request should have gone out.
expect(vi.mocked(fetch)).not.toHaveBeenCalledWith(
expect.stringContaining('/api/device'),
expect.anything(),
)
})
it('shows the raw token exactly once with a hard-to-miss warning, then hides it once dismissed', async () => {
mockFetch(async (input) => {
const url = String(input)
if (url.includes('/api/device')) {
return new Response(
JSON.stringify({
id: 'd-1',
name: 'attic-node',
token: 'raw-token-xyz-do-not-lose-me',
last_seen_at: null,
}),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
)
}
return new Response('{}', { status: 200 })
})
await renderPage()
await userEvent.type(screen.getByLabelText(/device name/i), 'attic-node')
await userEvent.click(screen.getByRole('button', { name: /pair device/i }))
await waitFor(() => expect(screen.getByText('raw-token-xyz-do-not-lose-me')).toBeTruthy())
expect(screen.getByText(/cannot be shown again/i)).toBeTruthy()
// The paired device should already be visible on the dashboard without
// waiting for a WS "devices" frame round-trip.
expect(screen.getByText('attic-node')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: /saved it/i }))
expect(screen.queryByText('raw-token-xyz-do-not-lose-me')).toBeNull()
})
it('shows an error message when the pairing request fails', async () => {
mockFetch(async () => new Response('{}', { status: 500 }))
await renderPage()
await userEvent.type(screen.getByLabelText(/device name/i), 'attic-node')
await userEvent.click(screen.getByRole('button', { name: /pair device/i }))
await waitFor(() => expect(screen.getByText(/pairing handshake failed/i)).toBeTruthy())
})
})
describe('DevicesPage — live dashboard', () => {
beforeEach(() => {
mockFetch(async () => new Response('{}', { status: 200 }))
})
it('lists paired devices from the initial "devices" frame', async () => {
const socket = await renderPage()
act(() => {
socket.emit({
type: 'devices',
devices: [
{ id: 'd-1', name: 'attic-node', last_seen_at: '2026-07-23T10:00:00Z' },
{ id: 'd-2', name: 'basement-node', last_seen_at: null },
],
})
})
expect(screen.getByText('attic-node')).toBeTruthy()
expect(screen.getByText('basement-node')).toBeTruthy()
})
it('updates a sensor reading in place as new frames arrive, without duplicating the row', async () => {
const socket = await renderPage()
act(() => {
socket.emit({
type: 'devices',
devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }],
})
})
act(() => {
socket.emit({
type: 'reading',
device_id: 'd-1',
sensor_type: 'temperature',
value: 21.4,
unit: 'c',
metadata: {},
at: '2026-07-23T10:00:01Z',
})
})
expect(screen.getByText('21.4')).toBeTruthy()
expect(screen.getAllByText('temperature')).toHaveLength(1)
act(() => {
socket.emit({
type: 'reading',
device_id: 'd-1',
sensor_type: 'temperature',
value: 22.1,
unit: 'c',
metadata: {},
at: '2026-07-23T10:00:05Z',
})
})
expect(screen.queryByText('21.4')).toBeNull()
expect(screen.getByText('22.1')).toBeTruthy()
expect(screen.getAllByText('temperature')).toHaveLength(1)
})
it('renders one row per distinct sensor_type, across multiple devices', async () => {
const socket = await renderPage()
act(() => {
socket.emit({
type: 'devices',
devices: [
{ id: 'd-1', name: 'attic-node', last_seen_at: null },
{ id: 'd-2', name: 'basement-node', last_seen_at: null },
],
})
socket.emit({
type: 'reading',
device_id: 'd-1',
sensor_type: 'temperature',
value: 21.4,
unit: 'c',
metadata: {},
at: 'x',
})
socket.emit({
type: 'reading',
device_id: 'd-1',
sensor_type: 'humidity',
value: 47.2,
unit: 'pct',
metadata: {},
at: 'x',
})
socket.emit({
type: 'reading',
device_id: 'd-2',
sensor_type: 'presence',
value: 1,
unit: 'bool',
metadata: {},
at: 'x',
})
})
expect(screen.getByText('temperature')).toBeTruthy()
expect(screen.getByText('humidity')).toBeTruthy()
expect(screen.getByText('presence')).toBeTruthy()
})
it('renders an unrecognized sensor_type safely — raw value + unit, no special-casing, no crash', async () => {
const socket = await renderPage()
act(() => {
socket.emit({
type: 'devices',
devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }],
})
})
expect(() => {
act(() => {
socket.emit({
type: 'reading',
device_id: 'd-1',
sensor_type: 'ectoplasm_flux',
value: { channels: [1, 2, 3] },
unit: 'quanta',
metadata: {},
at: 'x',
})
})
}).not.toThrow()
expect(screen.getByText('ectoplasm_flux')).toBeTruthy()
expect(screen.getByText(/quanta/)).toBeTruthy()
})
it('creates a device row for a reading whose device was not in the initial list, without crashing', async () => {
const socket = await renderPage()
act(() => {
socket.emit({ type: 'devices', devices: [] })
})
expect(() => {
act(() => {
socket.emit({
type: 'reading',
device_id: 'mystery-device',
sensor_type: 'presence',
value: true,
unit: 'bool',
metadata: {},
at: 'x',
})
})
}).not.toThrow()
expect(screen.getByText('presence')).toBeTruthy()
})
})
describe('formatSensorValue', () => {
it('formats numbers, trimming float noise without hardcoding decimal places', () => {
expect(formatSensorValue(21.4)).toBe('21.4')
expect(formatSensorValue(1013.2)).toBe('1013.2')
expect(formatSensorValue(1)).toBe('1')
})
it('formats booleans, null/undefined, arrays and plain objects without throwing', () => {
expect(formatSensorValue(true)).toBe('1')
expect(formatSensorValue(false)).toBe('0')
expect(formatSensorValue(null)).toBe('—')
expect(formatSensorValue(undefined)).toBe('—')
expect(formatSensorValue([1, 2, 3])).toBe('[1, 2, 3]')
expect(formatSensorValue({ a: 1 })).toBe('{"a":1}')
})
})

View File

@@ -0,0 +1,347 @@
// The Device Bay — pair ESP32 sensor nodes to your account and watch their
// telemetry live. The most overtly "hacker" surface in the app: this reads
// as a real instrument panel, not an occult one — terminal readouts, raw
// device tokens, packet-by-packet live updates.
//
// Pairing (POST /api/device) follows the same one-time-secret convention as
// a cloud API-key reveal: the raw token is shown exactly once, with a
// hard-to-miss warning, and is never retrievable again once dismissed —
// losing it means re-pairing the device (see the design spec's Contract
// section: the backend never stores or returns the raw token again).
//
// The live dashboard subscribes to /ws/device-feed. On connect the server
// sends the full device list (`{"type":"devices", ...}`); after that,
// `{"type":"reading", ...}` frames update each device's per-sensor_type row
// in place. sensor_type is free-form by design (the whole point of the
// hardware line, per the spec), so rendering is fully generic — an
// unrecognized sensor_type renders its raw value + unit with no
// special-casing, and can never crash the page.
import { useCallback, useEffect, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { DeviceFeedSocket } from '../lib/deviceFeed'
import type { DeviceFeedConnectionState, DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed'
import './DevicesPage.css'
type Reading = { value: unknown; unit: string; at: string }
type DeviceRow = DeviceSummary & { readings: Record<string, Reading> }
type PairState = 'idle' | 'pending' | 'error'
type PairResponse = {
id: string
name: string
token: string
last_seen_at?: string | null
}
/**
* Render a raw sensor value generically. sensor_type/value/unit are
* open-ended by contract — this must never assume a shape, and must never
* throw regardless of what an unrecognized sensor sends.
*/
export function formatSensorValue(value: unknown): string {
if (typeof value === 'number') {
if (!Number.isFinite(value)) return String(value)
// Trim float noise without hardcoding a fixed decimal count per unit.
return String(Number(value.toFixed(3)))
}
if (typeof value === 'boolean') return value ? '1' : '0'
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) return `[${value.map(formatSensorValue).join(', ')}]`
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return '[object]'
}
}
return String(value)
}
function formatTimestamp(iso: string, locale: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date)
}
export function DevicesPage() {
const { user, checking } = useAuth()
const { t } = useTranslation()
if (checking) {
return (
<div className="devices-loading" role="status">
<p className="loading-text">{t('common.loading')}</p>
</div>
)
}
if (!user) return <Navigate to="/enter" replace />
return <DevicesSession />
}
export default DevicesPage
// ---- the device bay itself ----
function DevicesSession() {
const { t, i18n } = useTranslation()
// ---- pairing ----
const [name, setName] = useState('')
const [pairState, setPairState] = useState<PairState>('idle')
const [nameError, setNameError] = useState<string | null>(null)
const [revealToken, setRevealToken] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// ---- live dashboard ----
const [devices, setDevices] = useState<Record<string, DeviceRow>>({})
const [order, setOrder] = useState<string[]>([])
const [connection, setConnection] = useState<DeviceFeedConnectionState>('connecting')
const upsertPairedDevice = useCallback((summary: DeviceSummary) => {
setDevices((prev) => ({
...prev,
[summary.id]: { ...summary, readings: prev[summary.id]?.readings ?? {} },
}))
setOrder((prev) => (prev.includes(summary.id) ? prev : [...prev, summary.id]))
}, [])
const submitPair = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (pairState === 'pending') return
const trimmed = name.trim()
if (!trimmed) {
setNameError(t('devices.pair.nameRequired', 'name the device before pairing'))
return
}
setNameError(null)
setPairState('pending')
fetch('/api/device', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
})
.then(async (res) => {
if (!res.ok) throw new Error(`pairing failed: ${res.status}`)
return (await res.json()) as PairResponse
})
.then((device) => {
setPairState('idle')
setName('')
setCopied(false)
setRevealToken(device.token)
upsertPairedDevice({
id: device.id,
name: device.name,
last_seen_at: device.last_seen_at ?? null,
})
})
.catch(() => setPairState('error'))
}
const copyToken = () => {
if (!revealToken) return
const clipboard = navigator.clipboard
if (!clipboard?.writeText) return
clipboard.writeText(revealToken).then(
() => setCopied(true),
() => undefined,
)
}
const dismissToken = () => {
setRevealToken(null)
setCopied(false)
}
useEffect(() => {
const socket = new DeviceFeedSocket()
const offFrame = socket.onFrame((frame: DeviceFeedFrame) => {
if (frame.type === 'devices') {
setDevices((prev) => {
const next: Record<string, DeviceRow> = {}
for (const d of frame.devices) {
next[d.id] = { ...d, readings: prev[d.id]?.readings ?? {} }
}
return next
})
setOrder(frame.devices.map((d) => d.id))
return
}
// frame.type === 'reading'
setDevices((prev) => {
const existing = prev[frame.device_id]
const base: DeviceRow = existing ?? {
id: frame.device_id,
name: frame.device_id,
last_seen_at: null,
readings: {},
}
return {
...prev,
[frame.device_id]: {
...base,
last_seen_at: frame.at,
readings: {
...base.readings,
[frame.sensor_type]: { value: frame.value, unit: frame.unit, at: frame.at },
},
},
}
})
setOrder((prev) => (prev.includes(frame.device_id) ? prev : [...prev, frame.device_id]))
})
const offState = socket.onState(setConnection)
socket.connect()
return () => {
offFrame()
offState()
socket.close()
}
}, [])
return (
<div className="devices-page">
<div className="devices-overlay" aria-hidden="true" />
<header className="devices-header">
<Link to="/seance" className="devices-back">
{t('devices.back', '← the threshold')}
</Link>
<h1 className="devices-title">{t('devices.title', 'DEVICE BAY')}</h1>
<p className="devices-subtitle">
{t('devices.subtitle', 'pair hardware to your account and watch its telemetry live')}
</p>
</header>
<section className="devices-pair" aria-label={t('devices.pair.title', 'PAIR A NEW DEVICE')}>
<h2 className="devices-section-title">
{t('devices.pair.title', 'PAIR A NEW DEVICE')}
</h2>
<form className="pair-form" onSubmit={submitPair}>
<label className="visually-hidden" htmlFor="device-name">
{t('devices.pair.nameLabel', 'device name')}
</label>
<input
id="device-name"
className="pair-input"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value)
if (nameError) setNameError(null)
}}
placeholder={t('devices.pair.namePlaceholder', 'e.g. attic-node-01')}
disabled={pairState === 'pending'}
/>
<button className="pair-submit" type="submit" disabled={pairState === 'pending'}>
{pairState === 'pending'
? t('devices.pair.pending', 'pairing…')
: t('devices.pair.submit', 'pair device')}
</button>
</form>
{nameError && (
<p className="pair-message is-error" role="alert">
{nameError}
</p>
)}
{pairState === 'error' && (
<p className="pair-message is-error" role="alert">
{t('devices.pair.error', 'the pairing handshake failed — try again')}
</p>
)}
{revealToken && (
<div className="token-reveal" role="alert">
<p className="token-reveal-warning">
<span className="token-reveal-icon" aria-hidden="true">
⚠
</span>
{t(
'devices.token.warning',
'copy this now — it cannot be shown again. losing it means re-pairing the device.',
)}
</p>
<div className="token-reveal-box">
<code className="token-reveal-value">{revealToken}</code>
<button type="button" className="token-copy" onClick={copyToken}>
{copied ? t('devices.token.copied', 'copied') : t('devices.token.copy', 'copy')}
</button>
</div>
<button type="button" className="token-confirm" onClick={dismissToken}>
{t('devices.token.confirm', "I've saved it — hide token")}
</button>
</div>
)}
</section>
<section className="devices-dashboard" aria-label={t('devices.dashboard.title', 'LIVE FEED')}>
<div className="dashboard-heading">
<h2 className="devices-section-title">{t('devices.dashboard.title', 'LIVE FEED')}</h2>
<span
className={`feed-dot feed-${connection}`}
title={t(`devices.dashboard.connection.${connection}`)}
aria-label={t(`devices.dashboard.connection.${connection}`)}
/>
</div>
{order.length === 0 ? (
<p className="devices-state">
{t('devices.dashboard.empty', 'no devices paired yet — pair one above to see it here')}
</p>
) : (
<div className="device-grid">
{order.map((id) => {
const device = devices[id]
if (!device) return null
const sensorTypes = Object.keys(device.readings)
return (
<article className="device-card" key={id} data-testid={`device-card-${id}`}>
<header className="device-card-head">
<h3 className="device-name">{device.name}</h3>
<p className="device-lastseen">
{t('devices.dashboard.lastSeen', 'last seen')}:{' '}
{device.last_seen_at
? formatTimestamp(device.last_seen_at, i18n.language)
: t('devices.dashboard.lastSeenNever', 'never')}
</p>
</header>
{sensorTypes.length === 0 ? (
<p className="device-noreadings">
{t('devices.dashboard.noReadings', 'awaiting first reading…')}
</p>
) : (
<div className="sensor-grid">
{sensorTypes.map((sensorType) => {
const r = device.readings[sensorType]
return (
<div className="sensor-row" key={sensorType}>
<span className="sensor-type">{sensorType}</span>
<span className="sensor-value flicker-soft">
{formatSensorValue(r.value)}{' '}
<span className="sensor-unit">{r.unit}</span>
</span>
</div>
)
})}
</div>
)}
</article>
)
})}
</div>
)}
</section>
</div>
)
}

View File

@@ -260,6 +260,10 @@ function SeanceSession({ username }: { username: string }) {
{t('nav.inventory', { defaultValue: 'inventory' })}
</Link>
<Link to="/devices" className="seance-codex-link">
{t('nav.devices', { defaultValue: 'devices' })}
</Link>
<span className="seance-user" title={username}>
{username}
</span>