feat: the vessel whispers in the séance (Workstream D)

DeviceWhisper: a compact live panel in the séance side column, shown only
when the seeker has paired hardware. One DeviceFeedSocket, readings folded
through the coldSpot cores, online dot with a 15s tick so a silent device
goes dark, cold-spot/pressure flags when active. Renders nothing on zero
devices or any fetch failure — the séance never errors because of this
panel. Socket closed on unmount.

Uses the real GET /api/device endpoint — the spec said /api/devices,
which does not exist; the agent verified against routes/device.py rather
than trusting the spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 17:19:50 +00:00
6 changed files with 605 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
/* DeviceWhisper — the vessel speaks in the séance side column. Same fused
hacker/witch chrome as RitualPanel: monospace terminal readout inside
sigil-lit framing, using SeancePage.css's token palette (bg #07070d ·
phosphor #7cffb2 · violet #b26bff · border rgba(124,255,178,0.18)). */
.device-whisper {
margin-top: 0.75rem;
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 6px;
padding: 0.7rem 0.8rem;
background:
radial-gradient(ellipse at 110% -20%, rgba(124, 255, 178, 0.1), transparent 60%),
rgba(10, 10, 18, 0.55);
box-shadow: 0 0 18px rgba(124, 255, 178, 0.06) inset;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
}
.device-whisper-title {
margin: 0 0 0.55rem;
font-family: 'Cinzel', serif;
font-size: 0.68rem;
letter-spacing: 0.24em;
color: rgba(124, 255, 178, 0.85);
text-shadow: 0 0 10px rgba(124, 255, 178, 0.35);
}
.whisper-device + .whisper-device {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px dashed rgba(124, 255, 178, 0.14);
}
.whisper-device-head {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.whisper-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
}
.whisper-dot.is-online {
background: #7cffb2;
box-shadow: 0 0 8px rgba(124, 255, 178, 0.9);
animation: whisperPulse 2.4s ease-in-out infinite;
}
.whisper-dot.is-offline {
background: rgba(211, 233, 219, 0.25);
}
@keyframes whisperPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.whisper-device-name {
font-size: 0.78rem;
letter-spacing: 0.08em;
color: #d3e9db;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whisper-waiting {
margin: 0.35rem 0 0;
font-size: 0.7rem;
letter-spacing: 0.05em;
}
.whisper-readings {
margin-top: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.whisper-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.6rem;
font-size: 0.72rem;
min-width: 0;
}
.whisper-sensor {
opacity: 0.55;
letter-spacing: 0.06em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whisper-value {
color: #7cffb2;
text-shadow: 0 0 6px rgba(124, 255, 178, 0.35);
white-space: nowrap;
}
.whisper-unit {
opacity: 0.55;
font-size: 0.85em;
}
.whisper-flag {
margin: 0.4rem 0 0;
font-size: 0.7rem;
letter-spacing: 0.05em;
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.whisper-flag-cold {
color: #9fd4ff;
background: rgba(120, 190, 255, 0.08);
border: 1px solid rgba(120, 190, 255, 0.25);
text-shadow: 0 0 8px rgba(120, 190, 255, 0.45);
}
.whisper-flag-pressure {
color: #b26bff;
background: rgba(178, 107, 255, 0.08);
border: 1px solid rgba(178, 107, 255, 0.3);
text-shadow: 0 0 8px rgba(178, 107, 255, 0.45);
}
/* Narrow vessels (≤390px): the panel stays a single tight column. */
@media (max-width: 480px) {
.device-whisper {
padding: 0.55rem 0.6rem;
}
.whisper-row {
font-size: 0.68rem;
}
}

View File

@@ -0,0 +1,185 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import type { DeviceFeedFrame } from '../lib/deviceFeed'
// ---- fake DeviceFeedSocket: same stubbing pattern as DevicesPage.test.tsx —
// module-mock the socket class so the component's frames come from the test,
// no real WebSocket involved. ----
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 mock above so the module picks up the faked socket.
const { DeviceWhisper } = await import('./DeviceWhisper')
function mockDevicesFetch(response: () => Response | Promise<Response>) {
vi.stubGlobal(
'fetch',
vi.fn(async () => response()),
)
}
function devicesResponse(devices: unknown[]): Response {
return new Response(JSON.stringify({ devices }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
beforeEach(() => {
instances = []
})
afterEach(() => {
vi.unstubAllGlobals()
})
function emitReading(
socket: FakeDeviceFeedSocket,
deviceId: string,
sensorType: string,
value: unknown,
unit: string,
at: string,
) {
act(() => {
socket.emit({
type: 'reading',
device_id: deviceId,
sensor_type: sensorType,
value,
unit,
metadata: {},
at,
})
})
}
describe('DeviceWhisper', () => {
it('renders nothing when the user has zero devices, and never opens the feed', async () => {
mockDevicesFetch(() => devicesResponse([]))
const { container } = render(<DeviceWhisper />)
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalled())
// Give the resolved fetch a tick to settle state.
await act(async () => {})
expect(container.innerHTML).toBe('')
expect(instances).toHaveLength(0)
})
it('renders nothing when the devices fetch fails — the séance never sees an error', async () => {
mockDevicesFetch(() => new Response('nope', { status: 500 }))
const { container } = render(<DeviceWhisper />)
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalled())
await act(async () => {})
expect(container.innerHTML).toBe('')
expect(instances).toHaveLength(0)
})
it('shows the device, renders readings from feed frames, and flags a cold spot', async () => {
mockDevicesFetch(() =>
devicesResponse([{ id: 'd-1', name: 'attic-node', last_seen_at: null }]),
)
render(<DeviceWhisper />)
await waitFor(() => expect(instances).toHaveLength(1))
const socket = instances[0]
expect(screen.getByText('attic-node')).toBeTruthy()
// No reading yet → offline dot.
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-offline')
const T0 = Date.now()
const iso = (ms: number) => new Date(ms).toISOString()
// Warm the temperature baseline, then drop hard — coldSpot core fires.
let t = T0 - 150_000
for (let i = 0; i < 4; i++) {
emitReading(socket, 'd-1', 'temperature', 21.0, 'c', iso(t))
t += 30_000
}
expect(screen.getByText('21')).toBeTruthy()
expect(screen.queryByTestId('whisper-coldspot-d-1')).toBeNull()
emitReading(socket, 'd-1', 'temperature', 15.0, 'c', iso(t))
emitReading(socket, 'd-1', 'presence', true, 'bool', iso(t))
expect(screen.getByText('15')).toBeTruthy()
expect(screen.getByText('presence')).toBeTruthy()
expect(screen.getByTestId('whisper-coldspot-d-1')).toBeTruthy()
// The last reading is recent → the dot flips online.
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-online')
})
it('marks a device whose last reading is older than 60s as offline', async () => {
mockDevicesFetch(() =>
devicesResponse([{ id: 'd-1', name: 'attic-node', last_seen_at: null }]),
)
render(<DeviceWhisper />)
await waitFor(() => expect(instances).toHaveLength(1))
const socket = instances[0]
// A stale reading (5 minutes ago) must not light the dot.
emitReading(
socket,
'd-1',
'temperature',
21.0,
'c',
new Date(Date.now() - 300_000).toISOString(),
)
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-offline')
// A fresh reading flips it online.
emitReading(socket, 'd-1', 'temperature', 21.1, 'c', new Date().toISOString())
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-online')
})
it('closes the socket on unmount', async () => {
mockDevicesFetch(() =>
devicesResponse([{ id: 'd-1', name: 'attic-node', last_seen_at: null }]),
)
const { unmount } = render(<DeviceWhisper />)
await waitFor(() => expect(instances).toHaveLength(1))
unmount()
expect(instances[0].closed).toBe(true)
})
})

View File

@@ -0,0 +1,254 @@
// DeviceWhisper — the vessel speaks in the séance. A compact live panel for
// the séance side column that surfaces the user's paired hardware (the
// Device Bay's ESP32 nodes) without leaving the ritual: device name, an
// online dot (a reading within the last minute), the latest readings in the
// terminal readout style, and the cold-spot / pressure-anomaly flags from
// lib/coldSpot.ts's pure classifiers.
//
// Contract with the séance: this panel must never error the séance. It
// fetches GET /api/device once on mount; if the seeker has no devices, or
// the fetch fails for any reason, it renders nothing at all. Only when at
// least one device exists does it open a DeviceFeedSocket (lib/deviceFeed),
// mirroring DevicesPage's wiring: `devices` frames replace the roster,
// `reading` frames update per-sensor rows in place and are threaded through
// the coldSpot rolling-baseline cores. The socket is closed on unmount.
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeviceFeedSocket } from '../lib/deviceFeed'
import type { DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed'
import {
applyPressureReading,
applyTemperatureReading,
initPressureBaseline,
initTemperatureBaseline,
} from '../lib/coldSpot'
import type { PressureBaselineState, TemperatureBaselineState } from '../lib/coldSpot'
import './DeviceWhisper.css'
/** A reading counts as "online" if it landed within the last minute. */
export const ONLINE_WINDOW_MS = 60_000
/** How often the online dot re-evaluates its window without a fresh frame
* forcing a render — a device that falls silent must visibly go dark. */
const ONLINE_TICK_MS = 15_000
type Reading = { value: unknown; unit: string }
type WhisperDevice = DeviceSummary & {
readings: Record<string, Reading>
/** Epoch ms of the newest reading frame (or last_seen_at), null if never. */
lastReadingAtMs: number | null
tempBaseline: TemperatureBaselineState
pressureBaseline: PressureBaselineState
isColdSpot: boolean
isPressureAnomaly: boolean
}
function initWhisperDevice(summary: DeviceSummary): WhisperDevice {
return {
...summary,
readings: {},
lastReadingAtMs: summary.last_seen_at ? parseAtMs(summary.last_seen_at) : null,
tempBaseline: initTemperatureBaseline(),
pressureBaseline: initPressureBaseline(),
isColdSpot: false,
isPressureAnomaly: false,
}
}
/** Same defensive timestamp parse as DevicesPage: a malformed `at` must not
* poison the baseline EMA or the online window. */
function parseAtMs(iso: string): number {
const ms = Date.parse(iso)
return Number.isFinite(ms) ? ms : Date.now()
}
/** Generic sensor-value rendering, following DevicesPage's rule that
* sensor_type/value/unit are open-ended and must never throw. Compact
* variant: objects/arrays collapse to a glyph rather than raw JSON — the
* séance readout is a whisper, not a packet dump. */
function formatValue(value: unknown): string {
if (typeof value === 'number') {
if (!Number.isFinite(value)) return String(value)
return String(Number(value.toFixed(2)))
}
if (typeof value === 'boolean') return value ? '1' : '0'
if (value === null || value === undefined) return '—'
if (typeof value === 'object') return '…'
return String(value)
}
function applyFrame(
device: WhisperDevice,
frame: { sensor_type: string; value: unknown; unit: string; at: string },
): WhisperDevice {
const atMs = parseAtMs(frame.at)
let next: WhisperDevice = {
...device,
lastReadingAtMs: atMs,
readings: {
...device.readings,
[frame.sensor_type]: { value: frame.value, unit: frame.unit },
},
}
// Non-numeric temperature/pressure stays a generic row (coldSpot's own
// no-op guard would ignore it anyway; skipping here keeps state stable).
if (typeof frame.value !== 'number' || !Number.isFinite(frame.value)) return next
if (frame.sensor_type === 'temperature') {
const r = applyTemperatureReading(device.tempBaseline, frame.value, atMs)
next = { ...next, tempBaseline: r.state, isColdSpot: r.isColdSpot }
} else if (frame.sensor_type === 'pressure') {
const r = applyPressureReading(device.pressureBaseline, frame.value, atMs)
next = { ...next, pressureBaseline: r.state, isPressureAnomaly: r.isPressureAnomaly }
}
return next
}
export function DeviceWhisper() {
const { t } = useTranslation()
// null = still asking / no vessel answers (render nothing either way
// until a device list with ≥1 entry arrives).
const [hasDevices, setHasDevices] = useState<boolean | null>(null)
const [devices, setDevices] = useState<Record<string, WhisperDevice>>({})
const [order, setOrder] = useState<string[]>([])
const [now, setNow] = useState(() => Date.now())
// One fetch on mount decides whether this panel exists at all.
useEffect(() => {
let cancelled = false
fetch('/api/device', { credentials: 'include' })
.then(async (res) => {
if (!res.ok) throw new Error(`devices fetch failed: ${res.status}`)
return (await res.json()) as { devices?: DeviceSummary[] }
})
.then((body) => {
if (cancelled) return
const list = Array.isArray(body.devices) ? body.devices : []
if (list.length === 0) {
setHasDevices(false)
return
}
const next: Record<string, WhisperDevice> = {}
for (const d of list) next[d.id] = initWhisperDevice(d)
setDevices(next)
setOrder(list.map((d) => d.id))
setHasDevices(true)
})
.catch(() => {
// The veil never scolds: a failed fetch means the panel simply
// does not manifest.
if (!cancelled) setHasDevices(false)
})
return () => {
cancelled = true
}
}, [])
// Live feed — only once we know the seeker has hardware.
useEffect(() => {
if (hasDevices !== true) return
const socket = new DeviceFeedSocket()
const offFrame = socket.onFrame((frame: DeviceFeedFrame) => {
if (frame.type === 'devices') {
setDevices((prev) => {
const next: Record<string, WhisperDevice> = {}
for (const d of frame.devices) {
next[d.id] = prev[d.id]
? { ...prev[d.id], name: d.name, last_seen_at: d.last_seen_at }
: initWhisperDevice(d)
}
return next
})
setOrder(frame.devices.map((d) => d.id))
return
}
// frame.type === 'reading'
setDevices((prev) => {
const existing =
prev[frame.device_id] ??
initWhisperDevice({ id: frame.device_id, name: frame.device_id, last_seen_at: null })
return { ...prev, [frame.device_id]: applyFrame(existing, frame) }
})
setOrder((prev) => (prev.includes(frame.device_id) ? prev : [...prev, frame.device_id]))
})
socket.connect()
return () => {
offFrame()
socket.close()
}
}, [hasDevices])
// The online window must lapse even when no frame arrives to re-render us.
useEffect(() => {
if (hasDevices !== true) return
const id = window.setInterval(() => setNow(Date.now()), ONLINE_TICK_MS)
return () => window.clearInterval(id)
}, [hasDevices])
if (hasDevices !== true || order.length === 0) return null
return (
<section className="device-whisper" data-testid="device-whisper">
<h3 className="device-whisper-title">
{t('seance.whisper.title', { defaultValue: 'THE VESSEL WHISPERS' })}
</h3>
{order.map((id) => {
const device = devices[id]
if (!device) return null
const online =
device.lastReadingAtMs !== null && now - device.lastReadingAtMs <= ONLINE_WINDOW_MS
const sensorTypes = Object.keys(device.readings)
return (
<article className="whisper-device" key={id} data-testid={`whisper-device-${id}`}>
<header className="whisper-device-head">
<span
className={`whisper-dot ${online ? 'is-online' : 'is-offline'}`}
data-testid={`whisper-dot-${id}`}
role="img"
aria-label={
online
? t('seance.whisper.online', { defaultValue: 'the vessel is awake' })
: t('seance.whisper.offline', { defaultValue: 'the vessel sleeps' })
}
/>
<span className="whisper-device-name">{device.name}</span>
</header>
{sensorTypes.length === 0 ? (
<p className="whisper-waiting dim">
{t('seance.whisper.waiting', { defaultValue: 'no whisper yet…' })}
</p>
) : (
<div className="whisper-readings">
{sensorTypes.map((sensorType) => {
const r = device.readings[sensorType]
return (
<div className="whisper-row" key={sensorType}>
<span className="whisper-sensor">{sensorType}</span>
<span className="whisper-value flicker-soft">
{formatValue(r.value)}{' '}
<span className="whisper-unit">{r.unit}</span>
</span>
</div>
)
})}
</div>
)}
{device.isColdSpot && (
<p className="whisper-flag whisper-flag-cold" data-testid={`whisper-coldspot-${id}`} role="status">
{t('seance.whisper.coldSpot', { defaultValue: '❄ a cold spot gathers around the vessel' })}
</p>
)}
{device.isPressureAnomaly && (
<p className="whisper-flag whisper-flag-pressure" data-testid={`whisper-pressure-${id}`} role="status">
{t('seance.whisper.pressureAnomaly', { defaultValue: '◎ the air presses close — the atmosphere shifts' })}
</p>
)}
</article>
)
})}
</section>
)
}
export default DeviceWhisper

View File

@@ -258,6 +258,14 @@
"body": "a diagnostic pulse only — nothing risked, nothing gained."
}
},
"whisper": {
"title": "THE VESSEL WHISPERS",
"online": "the vessel is awake",
"offline": "the vessel sleeps",
"waiting": "no whisper yet…",
"coldSpot": "❄ a cold spot gathers around the vessel",
"pressureAnomaly": "◎ the air presses close — the atmosphere shifts"
},
"toast": {
"rate_limited": "the veil is thin — slow your knocking",
"veil_crowded": "too many spirits crowd the line"

View File

@@ -258,6 +258,14 @@
"body": "solo un pulso de diagnóstico — no se arriesga ni se gana nada."
}
},
"whisper": {
"title": "EL RECIPIENTE SUSURRA",
"online": "el recipiente está despierto",
"offline": "el recipiente duerme",
"waiting": "aún ningún susurro…",
"coldSpot": "❄ un punto frío se acumula en torno al recipiente",
"pressureAnomaly": "◎ el aire se cierra — la atmósfera cambia"
},
"toast": {
"rate_limited": "el velo es delgado — golpea más despacio",
"veil_crowded": "demasiados espíritus se apiñan en la línea"

View File

@@ -24,6 +24,7 @@ import { GraphView } from '../components/GraphView'
import { Transcript } from '../components/Transcript'
import { EntityCard } from '../components/EntityCard'
import { RitualPanel } from '../components/RitualPanel'
import { DeviceWhisper } from '../components/DeviceWhisper'
import { JudgmentPanel } from '../components/JudgmentPanel'
import { TelemetryReadout } from '../components/TelemetryReadout'
import { PlanchetteMachine } from '../lib/planchette'
@@ -382,6 +383,7 @@ function SeanceSession({ username }: { username: string }) {
onToggleOpen={() => setDossierOpen((o) => !o)}
/>
<DeviceWhisper />
<RitualPanel />
<JudgmentPanel />