From b324aafec7f8dfae878679e304c12c3fb7df5490 Mon Sep 17 00:00:00 2001 From: Indiana Date: Tue, 28 Jul 2026 17:19:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20the=20vessel=20speaks=20in=20the=20s?= =?UTF-8?q?=C3=A9ance=20=E2=80=94=20DeviceWhisper=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workstream D of the usability wave (#8). A compact live panel in the séance side column, shown only when the seeker has paired hardware: device name, online dot (reading within 60s), latest readings in the terminal readout style, and cold-spot / pressure-anomaly flags from the lib/coldSpot.ts cores. Fetches GET /api/device once on mount; renders nothing on zero devices or fetch failure — the séance never sees an error from this panel. Socket closed on unmount. Co-Authored-By: Claude Fable 5 --- frontend/src/components/DeviceWhisper.css | 148 ++++++++++ .../src/components/DeviceWhisper.test.tsx | 185 +++++++++++++ frontend/src/components/DeviceWhisper.tsx | 254 ++++++++++++++++++ frontend/src/i18n/en.json | 8 + frontend/src/i18n/es.json | 8 + frontend/src/pages/SeancePage.tsx | 2 + 6 files changed, 605 insertions(+) create mode 100644 frontend/src/components/DeviceWhisper.css create mode 100644 frontend/src/components/DeviceWhisper.test.tsx create mode 100644 frontend/src/components/DeviceWhisper.tsx diff --git a/frontend/src/components/DeviceWhisper.css b/frontend/src/components/DeviceWhisper.css new file mode 100644 index 0000000..773f092 --- /dev/null +++ b/frontend/src/components/DeviceWhisper.css @@ -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; + } +} diff --git a/frontend/src/components/DeviceWhisper.test.tsx b/frontend/src/components/DeviceWhisper.test.tsx new file mode 100644 index 0000000..3a9b49a --- /dev/null +++ b/frontend/src/components/DeviceWhisper.test.tsx @@ -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('../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) { + 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() + 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() + 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() + + 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() + 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() + await waitFor(() => expect(instances).toHaveLength(1)) + unmount() + expect(instances[0].closed).toBe(true) + }) +}) diff --git a/frontend/src/components/DeviceWhisper.tsx b/frontend/src/components/DeviceWhisper.tsx new file mode 100644 index 0000000..9c6422e --- /dev/null +++ b/frontend/src/components/DeviceWhisper.tsx @@ -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 + /** 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(null) + const [devices, setDevices] = useState>({}) + const [order, setOrder] = useState([]) + 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 = {} + 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 = {} + 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 ( +
+

+ {t('seance.whisper.title', { defaultValue: 'THE VESSEL WHISPERS' })} +

+ {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 ( +
+
+ + {device.name} +
+ {sensorTypes.length === 0 ? ( +

+ {t('seance.whisper.waiting', { defaultValue: 'no whisper yet…' })} +

+ ) : ( +
+ {sensorTypes.map((sensorType) => { + const r = device.readings[sensorType] + return ( +
+ {sensorType} + + {formatValue(r.value)}{' '} + {r.unit} + +
+ ) + })} +
+ )} + {device.isColdSpot && ( +

+ {t('seance.whisper.coldSpot', { defaultValue: '❄ a cold spot gathers around the vessel' })} +

+ )} + {device.isPressureAnomaly && ( +

+ {t('seance.whisper.pressureAnomaly', { defaultValue: '◎ the air presses close — the atmosphere shifts' })} +

+ )} +
+ ) + })} +
+ ) +} + +export default DeviceWhisper diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 50ae8fa..d9ede9e 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -257,6 +257,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" diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index f8788d8..7eb4172 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -257,6 +257,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" diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index fc7cb51..496c5a6 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -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' @@ -362,6 +363,7 @@ function SeanceSession({ username }: { username: string }) { onToggleOpen={() => setDossierOpen((o) => !o)} /> +