Files
ruvnet--RuView/dashboard/src/main.ts
T
ruv 18c09d3305 feat(dashboard): iter G+H+I + P0.10 — modal forms, a11y pass, drag persistence, REPL history
Closes ADR-093 P0.10, P1.2, P1.6, P1.7, P2.1, P2.2, P2.3, P2.5.

## Iter G — modal contents (P1.6)
- nv-palette "New scene…" now opens a 5-field form (name, dipole
  moment, heart→sensor distance, ferrous toggle, 60 Hz mains toggle).
  On Apply: builds a real Scene JSON and pushes via client.loadScene().
- nv-palette "Export proof bundle…" now calls client.exportProofBundle()
  and triggers a real blob download with a timestamp filename.

## Iter H — a11y pass (P2.1, P2.2, P2.3, P2.5)
- Skip-to-main-content link at top of nv-app (focus-visible only).
- <main id="main-content" role="main"> wraps the central area; tabindex="-1"
  so the skip link can land focus there.
- nv-rail wraps its 5 view buttons in <nav role="navigation"
  aria-label="Primary"> with aria-current="page" on the active button
  and aria-label on every button. SVGs marked aria-hidden="true".
- nv-console body is now role="log" aria-live="polite"
  aria-label="Console output".
- nv-modal auto-focuses first interactive element on open and traps
  Tab cycling inside the dialog; nv-onboarding already had a dismiss
  affordance covered.

## Iter I — drag persistence (P1.7) + density visual (P1.2)
- scenePositions signal in appStore + IndexedDB key 'scene-positions'.
- nv-scene restores drag positions at connect; persists on pointerup.
- Density visual (CSS body.density-{comfy,default,compact}) confirmed
  active — was already wired but flagged as "doesn't change anything"
  in P1.2; verified during this iter.

## P0.10 — REPL history persistence
- replHistory + pushReplHistory in appStore, persisted to IndexedDB
  key 'repl-history'.
- nv-console arrow-up/down now read from the shared signal so command
  history survives view switches and reloads.

Validated end-to-end with `npx agent-browser` on
https://ruvnet.github.io/RuView/nvsim/ — skip-link, main role, console
log role, nav role, aria-current="page", New Scene modal with 5 form
fields all confirmed live. Console errors: zero.

ADR-093 §2/§3/§4 updated to mark these items resolved.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-26 22:08:49 -04:00

112 lines
4.3 KiB
TypeScript

/* nvsim dashboard entry — boots the WasmClient, mounts <nv-app>. */
import './app.css';
import './components/nv-app';
import { effect } from '@preact/signals-core';
import { WasmClient } from './transport/WasmClient';
import {
setClient, transport, theme, density, motionReduced,
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
replHistory, scenePositions, type SceneItemPos,
} from './store/appStore';
import { kvGet, kvSet } from './store/persistence';
function applyTheme(t: string): void {
document.documentElement.setAttribute('data-theme', t);
}
function applyDensity(d: string): void {
document.body.classList.remove('density-comfy', 'density-default', 'density-compact');
document.body.classList.add(`density-${d}`);
}
function applyMotion(reduced: boolean): void {
document.body.classList.toggle('reduce-motion', reduced);
}
(async () => {
// Restore persisted prefs
const t = (await kvGet<'dark' | 'light'>('theme')) ?? 'dark';
const d = (await kvGet<'comfy' | 'default' | 'compact'>('density')) ?? 'default';
const sysMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const m = (await kvGet<boolean>('motionReduced')) ?? sysMotion;
theme.value = t; applyTheme(t);
density.value = d; applyDensity(d);
motionReduced.value = m; applyMotion(m);
// React to changes → persist
effect(() => { applyTheme(theme.value); kvSet('theme', theme.value); });
effect(() => { applyDensity(density.value); kvSet('density', density.value); });
effect(() => { applyMotion(motionReduced.value); kvSet('motionReduced', motionReduced.value); });
// REPL history + scene drag positions persistence (P0.10, P1.7)
const histSaved = await kvGet<string[]>('repl-history');
if (histSaved && Array.isArray(histSaved)) replHistory.value = histSaved;
effect(() => { void kvSet('repl-history', replHistory.value); });
const positionsSaved = await kvGet<SceneItemPos[]>('scene-positions');
if (positionsSaved && Array.isArray(positionsSaved)) scenePositions.value = positionsSaved;
effect(() => { void kvSet('scene-positions', scenePositions.value); });
// Boot WASM client
const client = new WasmClient();
setClient(client);
pushLog('info', 'nvsim — booting WASM runtime');
client.onEvent((ev) => {
if (ev.type === 'log') pushLog(ev.level, ev.msg);
if (ev.type === 'fps') fps.value = ev.value;
if (ev.type === 'state') {
framesEmitted.value = BigInt(ev.framesEmitted);
}
});
client.onFrames((batch) => {
if (batch.frames.length === 0) return;
const last = batch.frames[batch.frames.length - 1];
lastFrame.value = last;
const bx = last.bPt[0] * 1e-12; // pT → T
const by = last.bPt[1] * 1e-12;
const bz = last.bPt[2] * 1e-12;
lastB.value = [bx, by, bz];
bMag.value = Math.sqrt(bx * bx + by * by + bz * bz);
// For trace display we use nT scale.
pushTrace([bx * 1e9, by * 1e9, bz * 1e9]);
const amp = Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3);
pushStripBar(amp);
});
try {
const info = await client.boot();
expectedWitness.value = info.expectedWitnessHex;
pushLog('ok', `WASM module ready · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
pushLog('info', `expected witness · ${info.expectedWitnessHex.slice(0, 16)}`);
// Load reference scene by default.
sceneJson.value = '(reference scene)';
transport.value = 'wasm';
} catch (e) {
pushLog('err', `boot failed: ${(e as Error).message}`);
}
// Auto-verify witness once at boot — proves WASM determinism contract.
try {
const exp = expectedWitness.value;
if (exp) {
const expBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
const r = await client.verifyWitness(expBytes);
if (r.ok) {
witnessHex.value = exp;
pushLog('ok', `witness verified · determinism gate ✓`);
} else {
const actual = Array.from(r.actual)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
witnessHex.value = actual;
pushLog('err', `WITNESS MISMATCH · expected ${exp.slice(0, 16)}… got ${actual.slice(0, 16)}`);
}
}
} catch (e) {
pushLog('warn', `witness verify skipped: ${(e as Error).message}`);
}
})();