Files
RuView/ui/utils/data-source-banner.js
T
Joeandclaude-flow fbecae4e02 fix(ui): never present invented data as measured
The dashboard flip-flopped between "absent" and "present_still" after
navigating away and back. The cause was not sensing. On a dropped socket the
client fell back to a built-in simulation and rendered synthetic values that
were indistinguishable from live ones, so the UI was showing made-up readings
and saying nothing about it.

That is the most damaging failure this UI can have. A sensing system that
invents data silently is worse than one that shows nothing, because every
downstream judgement -- including which nodes to move and whether a change
helped -- is then made against fiction.

Changes:

  - Client-side simulation is default-OFF and must be opted into explicitly.
  - An unreachable server retries forever instead of substituting data.
  - A banner states plainly whenever what is displayed is not live. The
    labels say what they mean: "NO DATA - SERVER UNREACHABLE" for an
    unreachable server, and "INVENTED DATA - NOT MEASURED" for simulation,
    replacing "OFFLINE - CLIENT SIMULATION", which read like a status rather
    than a warning.

Also fixes a reconnect race in the same path: two in-flight connects could each
install handlers, so a stale socket kept overwriting fresher state. That race is
what made the flip-flop intermittent and hard to attribute.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 11:42:16 -04:00

132 lines
4.1 KiB
JavaScript

// 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();