diff --git a/ui/app.js b/ui/app.js index 5c5bada6..55e4f741 100644 --- a/ui/app.js +++ b/ui/app.js @@ -28,6 +28,7 @@ import { i18n } from './utils/i18n.js'; import { ScreenshotTool } from './utils/screenshot.js'; import { UptimeClock } from './utils/uptime-clock.js'; import { QuickSettings } from './utils/quick-settings.js'; +import { dataSourceBanner } from './utils/data-source-banner.js'; class WiFiDensePoseApp { constructor() { @@ -98,6 +99,10 @@ class WiFiDensePoseApp { this.showBackendStatus('Backend unavailable — start sensing-server', 'warning'); } + // Mount the global provenance bar before the service starts, so the very + // first non-live state is visible on whichever tab is showing. + dataSourceBanner.init(); + // Start the sensing WebSocket service early so the dashboard and // live-demo tabs can show the correct data-source status immediately. sensingService.start(); @@ -360,6 +365,22 @@ class WiFiDensePoseApp { window.addEventListener('beforeunload', () => { this.cleanup(); }); + + // `beforeunload` above tears down every component and disconnects the + // sensing WebSocket. If the browser restores this page from bfcache on + // Back/Forward instead of doing a fresh navigation, that torn-down state + // comes back frozen exactly as-is — nothing re-runs DOMContentLoaded, so + // sensingService.start() never fires again and disposed components (e.g. + // Room Builder) never get recreated. `pageshow`'s `event.persisted` is + // the standard signal for "this is a bfcache restore, not a fresh load"; + // forcing a real reload here is simpler and more robust than trying to + // hand-reinitialize every component/connection back to a clean state. + window.addEventListener('pageshow', (event) => { + if (event.persisted) { + console.log('Page restored from bfcache after cleanup() — forcing reload'); + window.location.reload(); + } + }); } // Handle window resize diff --git a/ui/components/DashboardTab.js b/ui/components/DashboardTab.js index 9ecd0226..5e505fa9 100644 --- a/ui/components/DashboardTab.js +++ b/ui/components/DashboardTab.js @@ -95,7 +95,8 @@ export class DashboardTab { 'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' }, 'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' }, 'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' }, - 'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' }, + 'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' }, + 'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' }, }; const cfg = config[ds] || config['reconnecting']; el.className = `component-status status-${cfg.status}`; diff --git a/ui/components/LiveDemoTab.js b/ui/components/LiveDemoTab.js index 4dec767d..b4de941c 100644 --- a/ui/components/LiveDemoTab.js +++ b/ui/components/LiveDemoTab.js @@ -1318,7 +1318,8 @@ export class LiveDemoTab { const ds = sensingService.dataSource; if (ds === 'live') return 'Active \u2014 ESP32 Live'; if (ds === 'server-simulated') return 'Active \u2014 Simulated Data'; - if (ds === 'simulated') return 'Active \u2014 Offline Simulation'; + if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable'; + if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured'; return 'Connecting...'; } @@ -1331,7 +1332,8 @@ export class LiveDemoTab { 'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' }, 'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' }, 'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' }, - 'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' }, + 'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' }, + 'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' }, }; const cfg = config[ds] || config['reconnecting']; banner.textContent = cfg.text; diff --git a/ui/components/SensingTab.js b/ui/components/SensingTab.js index 33387eef..4ff233bf 100644 --- a/ui/components/SensingTab.js +++ b/ui/components/SensingTab.js @@ -228,7 +228,8 @@ export class SensingTab { 'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' }, 'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' }, 'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' }, - 'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' }, + 'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' }, + 'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' }, }; const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting; banner.textContent = cfg.text; diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js index 57e37cdc..09f2e6b9 100644 --- a/ui/services/sensing.service.js +++ b/ui/services/sensing.service.js @@ -37,6 +37,39 @@ const MAX_RECONNECT_ATTEMPTS = 20; const SIM_FALLBACK_AFTER_ATTEMPTS = 5; const SIMULATION_INTERVAL = 500; // ms +export const SIM_FALLBACK_STORAGE_KEY = 'ruview-client-simulation'; + +/** + * Whether the client may invent sensing frames when the server is unreachable. + * + * Default **off**, deliberately. The generated frames are plausible — presence + * derived from `Math.sin(t * 0.3)` crossing a fixed threshold produces a steady + * absent/present_still alternation that reads exactly like a real classifier + * struggling at its decision boundary. A reader with no reason to suspect the + * server had dropped will diagnose the sensing pipeline, and be wrong. That is + * not a hypothetical: it cost a full debugging session on 2026-08-28, chasing a + * "flip-flop" that was this function all along while the server sat at a + * perfectly steady presence=true. + * + * For an instrument, "no signal" is the honest reading and a useful one. + * Synthetic motion is only appropriate for a demo, so it must be asked for: + * `?simulate=1`, or `localStorage['ruview-client-simulation'] = 'true'`. + * + * Repository rule: never present synthetic output as measurement (CLAUDE.md). + */ +function clientSimulationAllowed() { + try { + const params = new URLSearchParams(window.location.search); + if (params.get('simulate') === '1') return true; + if (params.get('simulate') === '0') return false; + } catch { /* no window/location — non-browser test context */ } + try { + return localStorage.getItem(SIM_FALLBACK_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + class SensingService { constructor() { /** @type {WebSocket|null} */ @@ -46,6 +79,11 @@ class SensingService { this._reconnectAttempt = 0; this._reconnectTimer = null; this._simTimer = null; + // Guards the await window inside `_connect()` (see the comment there). + this._connectInFlight = false; + // Bumped per connect attempt and by `stop()`; lets a socket recognise that + // it has been abandoned and stop driving reconnect state. + this._connectEpoch = 0; // Connection state: disconnected | connecting | connected | reconnecting | simulated this._state = 'disconnected'; // Data-source label exposed to the UI: @@ -72,6 +110,10 @@ class SensingService { /** Stop the service entirely. */ stop() { + // Invalidate any attempt still inside its await window, so a socket opened + // after this call does not resurrect the service. + this._connectEpoch++; + this._connectInFlight = false; this._clearTimers(); if (this._ws) { this._ws.close(1000, 'client stop'); @@ -114,7 +156,10 @@ class SensingService { * Current data source label. * "live" — frames are arriving from the real ESP32 over WebSocket * "reconnecting" — WebSocket disconnected; actively retrying, no frames emitted - * "simulated" — max reconnect attempts exhausted; emitting synthetic frames + * "unreachable" — retries exhausted, client simulation off; NO frames emitted + * and whatever is on screen is the last live reading, stale + * "simulated" — client simulation explicitly enabled and running; every + * frame on screen is invented */ get dataSource() { return this._dataSource; @@ -129,6 +174,22 @@ class SensingService { // reconnects would fail on the second attempt. async _connect() { if (this._ws && this._ws.readyState <= WebSocket.OPEN) return; + // The readyState guard above cannot stand alone: this function awaits + // ticket minting before it assigns `this._ws`, so two callers entering + // during that window BOTH pass the guard and BOTH open a socket. The + // second assignment orphans the first — a socket nobody references, still + // open on the server, whose `onclose` fires later and calls + // `_scheduleReconnect()` even though a healthy socket is live. That walks + // `_reconnectAttempt` upward with no outage and eventually trips the + // simulation fallback. Observed on the server as connect/disconnect churn + // on every page load. + if (this._connectInFlight) return; + this._connectInFlight = true; + + // Identifies this attempt for the lifetime of its socket. `stop()` and any + // superseding attempt bump it, so a late event from an abandoned socket is + // recognised and ignored rather than driving reconnect state. + const epoch = ++this._connectEpoch; this._setState('connecting'); @@ -140,15 +201,34 @@ class SensingService { // predating ADR-272, connecting without a ticket is correct. } + if (epoch !== this._connectEpoch) { + // Superseded or stopped while minting. The ticket is single-use and now + // spent; the current attempt mints its own. + this._connectInFlight = false; + return; + } + + let ws; try { - this._ws = new WebSocket(url); + ws = new WebSocket(url); } catch (err) { console.warn('[Sensing] WebSocket constructor failed:', err.message); + this._connectInFlight = false; this._fallbackToSimulation(); return; } - this._ws.onopen = () => { + this._ws = ws; + this._connectInFlight = false; + + /** True while `ws` is still the socket this service is driving. */ + const isCurrent = () => epoch === this._connectEpoch && this._ws === ws; + + ws.onopen = () => { + if (!isCurrent()) { + ws.close(1000, 'superseded'); + return; + } console.info('[Sensing] Connected to', SENSING_WS_URL); this._reconnectAttempt = 0; this._stopSimulation(); @@ -158,7 +238,8 @@ class SensingService { this._detectServerSource(); }; - this._ws.onmessage = (evt) => { + ws.onmessage = (evt) => { + if (!isCurrent()) return; try { const data = JSON.parse(evt.data); this._handleData(data); @@ -167,11 +248,17 @@ class SensingService { } }; - this._ws.onerror = () => { + ws.onerror = () => { // onerror is always followed by onclose, so we handle reconnect there }; - this._ws.onclose = (evt) => { + ws.onclose = (evt) => { + if (!isCurrent()) { + // An abandoned socket closing is not an outage. Reconnect state + // belongs to whichever attempt is current. + console.debug('[Sensing] Ignoring close from superseded socket'); + return; + } console.info('[Sensing] Connection closed (code=%d)', evt.code); this._ws = null; if (evt.code !== 1000) { @@ -184,18 +271,31 @@ class SensingService { } _scheduleReconnect() { + // A retry is already pending; stacking timers would multiply the attempt + // count for a single outage. + if (this._reconnectTimer) return; + if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { - console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS); - this._fallbackToSimulation(); - return; + if (clientSimulationAllowed()) { + console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS); + this._fallbackToSimulation(); + return; + } + // With simulation off there is nothing to give up *for*: keep retrying at + // the ceiling delay so the UI recovers by itself when the server returns, + // rather than requiring a hard refresh. + this._setDataSource('unreachable'); } const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)]; - this._reconnectAttempt++; + // Hold at the cap rather than counting into the thousands over a long + // outage; the delay is already saturated and the number is only read by a + // human in the console. + this._reconnectAttempt = Math.min(this._reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS); console.info('[Sensing] Reconnecting in %dms (attempt %d/%d)', delay, this._reconnectAttempt, MAX_RECONNECT_ATTEMPTS); this._setState('reconnecting'); - this._setDataSource('reconnecting'); + if (this._dataSource !== 'unreachable') this._setDataSource('reconnecting'); this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; @@ -205,13 +305,23 @@ class SensingService { // Only start simulation after several failed attempts so a brief hiccup // does not immediately switch the UI to "SIMULATED DATA". if (this._reconnectAttempt >= SIM_FALLBACK_AFTER_ATTEMPTS && this._state !== 'simulated') { - this._fallbackToSimulation(); + if (clientSimulationAllowed()) { + this._fallbackToSimulation(); + } else { + // Say "the server is gone" instead of inventing a room. + this._setDataSource('unreachable'); + } } } // ---- Simulation fallback ----------------------------------------------- _fallbackToSimulation() { + if (!clientSimulationAllowed()) { + this._setDataSource('unreachable'); + this._scheduleReconnect(); + return; + } this._setState('simulated'); this._setDataSource('simulated'); if (this._simTimer) return; // already running diff --git a/ui/style.css b/ui/style.css index 55c1905e..f8ffd952 100644 --- a/ui/style.css +++ b/ui/style.css @@ -1804,7 +1804,11 @@ canvas { flex-direction: column; gap: var(--space-12); overflow-y: auto; + /* Without this, `overflow-y: auto` alone still resolves the x axis to auto, + so anything marginally too wide scrolls sideways instead of wrapping. */ + overflow-x: hidden; max-height: 600px; + min-width: 0; } .sensing-card { @@ -1812,6 +1816,10 @@ canvas { border: 1px solid var(--color-card-border); border-radius: var(--radius-md); padding: var(--space-12); + /* Flex items default to min-width:auto, which refuses to shrink below their + content — one wide child then widens the whole 320px column and puts a + horizontal scrollbar on the panel. Cards must shrink to the column. */ + min-width: 0; } .sensing-card-title { diff --git a/ui/utils/data-source-banner.js b/ui/utils/data-source-banner.js new file mode 100644 index 00000000..d5a412eb --- /dev/null +++ b/ui/utils/data-source-banner.js @@ -0,0 +1,131 @@ +// Global data-source banner. +// +// The per-tab banner in SensingTab was correct and still invisible where it +// mattered: a reader on Dashboard or Live Demo never saw it, so a screen full +// of client-invented sensing frames was indistinguishable from measurement. +// This bar is fixed to the viewport and present on every tab, so the provenance +// of what is on screen is never more than a glance away. +// +// It shows nothing at all while data is live. An always-on badge becomes +// wallpaper; appearing only when the reading is not trustworthy keeps it +// meaningful. + +import { sensingService } from '../services/sensing.service.js'; + +const STYLE_ID = 'ruview-data-source-banner-style'; +const BAR_ID = 'ruview-data-source-banner'; + +const CONFIG = { + // 'live' is intentionally absent: no banner is shown for trustworthy data. + 'server-simulated': { + text: 'SYNTHETIC DATA — the server is generating this, no hardware is being read', + bg: '#7c3a00', + fg: '#ffd9a0', + }, + reconnecting: { + text: 'RECONNECTING — the display is frozen at the last live reading', + bg: '#33302a', + fg: '#e8dcc0', + }, + unreachable: { + text: 'SERVER UNREACHABLE — nothing on this screen is current', + bg: '#5c1010', + fg: '#ffc9c9', + }, + simulated: { + text: 'SIMULATED — every value on this screen is invented by your browser, not measured', + bg: '#6b0f6b', + fg: '#ffc9ff', + }, +}; + +const CSS = ` +#${BAR_ID} { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100000; + display: none; + padding: 7px 14px; + font: 600 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; + text-align: center; + border-bottom: 1px solid rgba(0, 0, 0, 0.45); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); +} +#${BAR_ID}.is-visible { display: block; } +/* Simulated data is the dangerous case — it looks real. Make the bar move so + it cannot be mistaken for a static decoration. */ +#${BAR_ID}.is-simulated { animation: ruview-dsb-pulse 1.6s ease-in-out infinite; } +@keyframes ruview-dsb-pulse { 50% { filter: brightness(1.45); } } +@media (prefers-reduced-motion: reduce) { + #${BAR_ID}.is-simulated { animation: none; } +} +body.ruview-has-data-source-banner { padding-top: 30px; } +`; + +export class DataSourceBanner { + constructor() { + this._bar = null; + this._unsubscribe = null; + } + + init() { + if (typeof document === 'undefined') return; + + if (!document.getElementById(STYLE_ID)) { + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + } + + this._bar = document.getElementById(BAR_ID); + if (!this._bar) { + this._bar = document.createElement('div'); + this._bar.id = BAR_ID; + this._bar.setAttribute('role', 'status'); + // Announce changes, but don't interrupt: the reader is mid-task and the + // message describes context, not an error to act on right now. + this._bar.setAttribute('aria-live', 'polite'); + document.body.appendChild(this._bar); + } + + // dataSource changes are published on the state-listener channel. + this._unsubscribe = sensingService.onStateChange(() => this.render()); + this.render(); + } + + render() { + if (!this._bar) return; + const cfg = CONFIG[sensingService.dataSource]; + + if (!cfg) { + this._bar.classList.remove('is-visible', 'is-simulated'); + document.body.classList.remove('ruview-has-data-source-banner'); + return; + } + + this._bar.textContent = cfg.text; + this._bar.style.background = cfg.bg; + this._bar.style.color = cfg.fg; + this._bar.classList.add('is-visible'); + this._bar.classList.toggle('is-simulated', sensingService.dataSource === 'simulated'); + document.body.classList.add('ruview-has-data-source-banner'); + } + + dispose() { + if (this._unsubscribe) { + this._unsubscribe(); + this._unsubscribe = null; + } + if (this._bar && this._bar.parentNode) { + this._bar.parentNode.removeChild(this._bar); + } + this._bar = null; + document.body.classList.remove('ruview-has-data-source-banner'); + } +} + +export const dataSourceBanner = new DataSourceBanner();