+ ${(['all', 'info', 'warn', 'err', 'dbg'] as const).map((k) => html`
+
+ `)}
+
+
+
+
+
+
+
+ ${visible.map((l) => {
+ const ts = new Date(l.ts);
+ const tsStr = `${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`;
+ // Use innerHTML pass-through via unsafe-html alt: inject raw html via property
+ return html`
+
${tsStr}
+
${l.level}
+
+
`;
+ })}
+
+ {
+ const c = getClient(); if (!c) return;
+ witnessVerified.value = 'pending';
+ pushLog('info', 'verifying witness over 256 frames…');
+ try {
+ const exp = expectedWitness.value;
+ 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 c.verifyWitness(expBytes);
+ if (r.ok) {
+ witnessVerified.value = 'ok';
+ witnessHex.value = exp;
+ pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`);
+ } else {
+ witnessVerified.value = 'fail';
+ const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
+ witnessHex.value = actual;
+ pushLog('err', `WITNESS MISMATCH actual=${actual.slice(0, 16)}…`);
+ }
+ } catch (e) {
+ witnessVerified.value = 'fail';
+ pushLog('err', `verify failed: ${(e as Error).message}`);
+ }
+ }
+
+ private renderSignalTab() {
+ const W = 320, H = 130, cy = 65, scale = 22;
+ const cap = 200;
+ const make = (arr: number[]) => {
+ let p = '';
+ arr.forEach((v, i) => {
+ const x = (i / Math.max(1, cap - 1)) * W;
+ const y = cy - v * scale;
+ p += (i === 0 ? 'M' : 'L') + ` ${x.toFixed(1)} ${y.toFixed(1)} `;
+ });
+ return p;
+ };
+
+ return html`
+
+
+ B-vector trace
+ 3-axis · nT
+
+
+
+
+
+
+ Frame stream
+ live
+
+
+ ${stripBars.value.map((v) => html`
`)}
+
+
+ `;
+ }
+
+ private renderFrameTab() {
+ const f = lastFrame.value;
+ const bytes = f?.raw;
+ let hex = '';
+ if (bytes) {
+ const arr = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0'));
+ hex = arr.slice(0, 60).join(' ');
+ }
+ return html`
+
+
+ MagFrame v1 fields
+ 60 B
+
+
+ | magic | ${f ? '0x' + f.magic.toString(16).toUpperCase() : '—'} |
+ | version | ${f?.version ?? '—'} |
+ | flags | 0x${(f?.flags ?? 0).toString(16).padStart(4, '0')} |
+ | sensor_id | ${f?.sensorId ?? '—'} |
+ | t_us | ${f ? f.tUs.toString() : '—'} |
+ | b_pT[0] | ${f ? f.bPt[0].toFixed(1) : '—'} |
+ | b_pT[1] | ${f ? f.bPt[1].toFixed(1) : '—'} |
+ | b_pT[2] | ${f ? f.bPt[2].toFixed(1) : '—'} |
+ | noise_floor | ${f ? f.noiseFloorPtSqrtHz.toFixed(2) : '—'} |
+ | temp_K | ${f ? f.temperatureK.toFixed(1) : '—'} |
+
+
+
+
+ Hex dump
+ LE
+
+
${hex || '—'}
+
+ `;
+ }
+
+ private renderWitnessTab() {
+ const status = witnessVerified.value;
+ const cls = status === 'ok' ? 'ok' : status === 'fail' ? 'fail' : '';
+ const label =
+ status === 'pending' ? 'Verifying…' :
+ status === 'ok' ? '✓ Witness verified · determinism gate' :
+ status === 'fail' ? '✗ Witness mismatch · audit required' :
+ 'Verify witness';
+ return html`
+
+
+ Expected (Proof::EXPECTED_WITNESS_HEX)
+ SHA-256
+
+
${expectedWitness.value || '(loading…)'}
+
+
+
+ Actual (last verify)
+ SHA-256
+
+
${witnessHex.value || '(not verified yet)'}
+
+
+ `;
+ }
+
+ override render() {
+ return html`
+
+
+
+
+
+
+ ${this.tab === 'signal' ? this.renderSignalTab()
+ : this.tab === 'frame' ? this.renderFrameTab()
+ : this.renderWitnessTab()}
+
+ `;
+ }
+}
diff --git a/dashboard/src/components/nv-modal.ts b/dashboard/src/components/nv-modal.ts
new file mode 100644
index 00000000..3306a932
--- /dev/null
+++ b/dashboard/src/components/nv-modal.ts
@@ -0,0 +1,123 @@
+/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */
+import { LitElement, html, css } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+
+interface ModalButton {
+ label: string;
+ variant?: 'ghost' | 'primary' | 'danger';
+ onClick?: () => void;
+}
+interface ModalReq {
+ title: string;
+ body: string;
+ buttons?: ModalButton[];
+}
+
+@customElement('nv-modal')
+export class NvModal extends LitElement {
+ @state() private open = false;
+ @state() private mTitle = '';
+ @state() private mBody = '';
+ @state() private buttons: ModalButton[] = [];
+
+ static styles = css`
+ :host {
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,0.55);
+ backdrop-filter: blur(4px);
+ z-index: 200;
+ display: grid; place-items: center;
+ opacity: 0; pointer-events: none;
+ transition: opacity 0.18s;
+ }
+ :host([open]) { opacity: 1; pointer-events: auto; }
+ .modal {
+ background: var(--bg-1);
+ border: 1px solid var(--line-2);
+ border-radius: var(--radius);
+ box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
+ width: min(520px, 92vw);
+ max-height: 86vh;
+ display: flex; flex-direction: column;
+ transform: translateY(12px) scale(0.98);
+ transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
+ }
+ :host([open]) .modal { transform: translateY(0) scale(1); }
+ .h {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ display: flex; align-items: center; justify-content: space-between;
+ }
+ .h .ttl { font-size: 14px; font-weight: 600; }
+ .body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
+ .f {
+ padding: 12px 16px;
+ border-top: 1px solid var(--line);
+ display: flex; gap: 8px; justify-content: flex-end;
+ }
+ button {
+ padding: 6px 12px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ cursor: pointer;
+ font-family: inherit;
+ border: 1px solid var(--line);
+ background: var(--bg-2); color: var(--ink);
+ }
+ button.ghost { background: transparent; }
+ button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
+ button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
+ .close {
+ width: 28px; height: 28px;
+ background: transparent; border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--ink-2);
+ }
+ `;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ window.addEventListener('nv-modal', this.onModal as EventListener);
+ window.addEventListener('keydown', this.onKey);
+ }
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ window.removeEventListener('nv-modal', this.onModal as EventListener);
+ window.removeEventListener('keydown', this.onKey);
+ }
+
+ private onModal = (e: Event): void => {
+ const r = (e as CustomEvent).detail as ModalReq;
+ this.mTitle = r.title; this.mBody = r.body;
+ this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }];
+ this.open = true; this.setAttribute('open', '');
+ };
+
+ private onKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape' && this.open) this.close();
+ };
+
+ private close(): void { this.open = false; this.removeAttribute('open'); }
+ private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); }
+
+ override render() {
+ return html`
+
+
+
${this.mTitle}
+
+
+
+
+ ${this.buttons.map((b) => html`
+
+ `)}
+
+
+ `;
+ }
+}
+
+export function openModal(req: ModalReq): void {
+ window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
+}
diff --git a/dashboard/src/components/nv-palette.ts b/dashboard/src/components/nv-palette.ts
new file mode 100644
index 00000000..86657b15
--- /dev/null
+++ b/dashboard/src/components/nv-palette.ts
@@ -0,0 +1,180 @@
+/* Command palette ⌘K. */
+import { LitElement, html, css } from 'lit';
+import { customElement, state, query } from 'lit/decorators.js';
+import { toast } from './nv-toast';
+import { openModal } from './nv-modal';
+import {
+ getClient, theme, expectedWitness, witnessHex, witnessVerified, pushLog, running,
+} from '../store/appStore';
+
+interface Cmd { ico: string; label: string; kbd?: string; run: () => void; }
+
+@customElement('nv-palette')
+export class NvPalette extends LitElement {
+ @state() private open = false;
+ @state() private filter = '';
+ @state() private idx = 0;
+ @query('#palette-input') private inputEl!: HTMLInputElement;
+
+ static styles = css`
+ :host {
+ position: fixed; inset: 0; z-index: 220;
+ background: rgba(0,0,0,0.5);
+ opacity: 0; pointer-events: none;
+ transition: opacity 0.15s;
+ display: flex; justify-content: center; padding-top: 12vh;
+ backdrop-filter: blur(4px);
+ }
+ :host([open]) { opacity: 1; pointer-events: auto; }
+ .palette {
+ width: min(560px, 92vw);
+ background: var(--bg-1);
+ border: 1px solid var(--line-2);
+ border-radius: var(--radius);
+ box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
+ overflow: hidden;
+ display: flex; flex-direction: column;
+ max-height: 60vh;
+ }
+ .input {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ }
+ input {
+ width: 100%;
+ background: transparent; border: none; outline: none;
+ color: var(--ink); font-size: 14px;
+ font-family: inherit;
+ }
+ .list { flex: 1; overflow-y: auto; padding: 4px; }
+ .item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 12.5px;
+ }
+ .item.active { background: var(--bg-3); }
+ .item .ico { width: 20px; text-align: center; color: var(--accent); }
+ .item .lbl { flex: 1; }
+ .item .kbd {
+ font-family: var(--mono); font-size: 10.5px;
+ color: var(--ink-3);
+ padding: 1px 5px; background: var(--bg-3); border-radius: 4px;
+ }
+ `;
+
+ private cmds: Cmd[] = [
+ { ico: '▶', label: 'Run pipeline', kbd: 'Space', run: async () => { await getClient()?.run(); running.value = true; toast('Pipeline running', '▶'); } },
+ { ico: '❚', label: 'Pause pipeline', run: async () => { await getClient()?.pause(); running.value = false; toast('Paused', '❚❚'); } },
+ { ico: '⟳', label: 'Reset pipeline', kbd: '⌘R', run: () => openModal({
+ title: 'Reset pipeline?',
+ body: 'Clears the frame stream and rewinds t to 0.
',
+ buttons: [
+ { label: 'Cancel', variant: 'ghost' },
+ { label: 'Reset', variant: 'danger', onClick: async () => { await getClient()?.reset(); pushLog('warn', 'pipeline reset · t=0'); toast('Pipeline reset', '⟳'); } },
+ ],
+ }) },
+ { ico: '✓', label: 'Verify witness', run: async () => {
+ const c = getClient(); if (!c) return;
+ witnessVerified.value = 'pending';
+ const exp = expectedWitness.value;
+ const eb = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) eb[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
+ const r = await c.verifyWitness(eb);
+ if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; toast('Witness verified', '✓'); }
+ else { witnessVerified.value = 'fail'; toast('Witness mismatch!', '✗'); }
+ } },
+ { ico: '☼', label: 'Toggle theme', kbd: '⌘/', run: () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; } },
+ { ico: '⚙', label: 'Open settings', kbd: '⌘,', run: () => window.dispatchEvent(new CustomEvent('open-settings')) },
+ { ico: '?', label: 'Keyboard shortcuts…', run: () => openModal({
+ title: 'Keyboard shortcuts',
+ body: `
+
⌘K / Ctrl K
Command palette
+
Space
Play / pause
+
⌘R
Reset
+
⌘,
Settings
+
⌘/
Toggle theme
+
\`
Debug HUD
+
1 · 2 · 3
Inspector tabs
+
Esc
Close modal/palette
+
/
Focus REPL
+
`,
+ buttons: [{ label: 'Close', variant: 'primary' }],
+ }) },
+ { ico: 'i', label: 'About nvsim…', run: () => openModal({
+ title: 'About nvsim',
+ body: `nvsim is a deterministic, byte-reproducible forward simulator for nitrogen-vacancy diamond magnetometry.
+ This dashboard runs nvsim as WASM in a Web Worker. Same (scene, config, seed) → byte-identical SHA-256 witness across runs and machines.
+ License: MIT OR Apache-2.0 · See ADR-089, ADR-092.
`,
+ buttons: [{ label: 'Close', variant: 'primary' }],
+ }) },
+ ];
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ window.addEventListener('keydown', this.onKey);
+ window.addEventListener('nv-palette', this.onOpen as EventListener);
+ }
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ window.removeEventListener('keydown', this.onKey);
+ window.removeEventListener('nv-palette', this.onOpen as EventListener);
+ }
+
+ private onKey = (e: KeyboardEvent): void => {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ this.openPal();
+ } else if (e.key === 'Escape' && this.open) {
+ this.closePal();
+ } else if (this.open) {
+ if (e.key === 'ArrowDown') { this.idx = Math.min(this.cmds.length - 1, this.idx + 1); e.preventDefault(); }
+ else if (e.key === 'ArrowUp') { this.idx = Math.max(0, this.idx - 1); e.preventDefault(); }
+ else if (e.key === 'Enter') { this.runIdx(); e.preventDefault(); }
+ }
+ };
+
+ private onOpen = (): void => this.openPal();
+
+ private openPal(): void {
+ this.open = true; this.setAttribute('open', '');
+ this.filter = ''; this.idx = 0;
+ setTimeout(() => this.inputEl?.focus(), 0);
+ }
+ private closePal(): void { this.open = false; this.removeAttribute('open'); }
+
+ private filtered(): Cmd[] {
+ if (!this.filter.trim()) return this.cmds;
+ const q = this.filter.toLowerCase();
+ return this.cmds.filter((c) => c.label.toLowerCase().includes(q));
+ }
+
+ private runIdx(): void {
+ const f = this.filtered();
+ const c = f[this.idx];
+ if (c) { c.run(); this.closePal(); }
+ }
+
+ override render() {
+ const items = this.filtered();
+ return html`
+
+
+ { this.filter = (e.target as HTMLInputElement).value; this.idx = 0; }} />
+
+
+ ${items.map((c, i) => html`
+
{ this.idx = i; this.runIdx(); }}>
+ ${c.ico}
+ ${c.label}
+ ${c.kbd ? html`${c.kbd}` : ''}
+
+ `)}
+
+
+ `;
+ }
+}
diff --git a/dashboard/src/components/nv-rail.ts b/dashboard/src/components/nv-rail.ts
new file mode 100644
index 00000000..bca45c0f
--- /dev/null
+++ b/dashboard/src/components/nv-rail.ts
@@ -0,0 +1,85 @@
+/* Left rail navigation. Emits `navigate` events for view switching. */
+import { LitElement, html, css } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import type { View } from './nv-app';
+
+@customElement('nv-rail')
+export class NvRail extends LitElement {
+ @property() view: View = 'scene';
+
+ static styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 10px 0;
+ gap: 4px;
+ background: var(--bg-1);
+ border-right: 1px solid var(--line);
+ }
+ .logo {
+ width: 36px; height: 36px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
+ display: grid; place-items: center;
+ color: #1a0f00;
+ font-weight: 700;
+ font-family: var(--mono);
+ font-size: 11px;
+ margin-bottom: 14px;
+ box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
+ }
+ .btn {
+ width: 36px; height: 36px;
+ border-radius: 8px;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--ink-3);
+ display: grid; place-items: center;
+ transition: all 0.15s;
+ position: relative;
+ cursor: pointer;
+ }
+ .btn:hover { color: var(--ink); background: var(--bg-2); }
+ .btn.active {
+ color: var(--ink);
+ background: var(--bg-3);
+ border-color: var(--line-2);
+ }
+ .btn.active::before {
+ content: ''; position: absolute; left: -10px; top: 8px; bottom: 8px;
+ width: 2px; background: var(--accent); border-radius: 2px;
+ }
+ .spacer { flex: 1; }
+ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; }
+ `;
+
+ private navigate(v: View): void {
+ this.dispatchEvent(new CustomEvent('navigate', { detail: v }));
+ }
+
+ override render() {
+ return html`
+ NV
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/dashboard/src/components/nv-scene.ts b/dashboard/src/components/nv-scene.ts
new file mode 100644
index 00000000..fc1edace
--- /dev/null
+++ b/dashboard/src/components/nv-scene.ts
@@ -0,0 +1,194 @@
+/* Scene canvas — SVG with draggable sources, NV crystal sensor, field lines, mini ODMR. */
+import { LitElement, html, css, svg } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { effect } from '@preact/signals-core';
+import { lastB, bMag, fps, snr, motionReduced } from '../store/appStore';
+
+interface SceneItem { id: string; x: number; y: number; color: string; name: string; }
+
+@customElement('nv-scene')
+export class NvScene extends LitElement {
+ @state() private items: SceneItem[] = [
+ { id: 'rebar', x: 740, y: 240, color: 'oklch(0.72 0.18 330)', name: 'rebar.steel' },
+ { id: 'heart', x: 220, y: 180, color: 'oklch(0.78 0.14 195)', name: 'heart_proxy' },
+ { id: 'mains', x: 180, y: 380, color: 'oklch(0.72 0.18 330)', name: 'mains_60Hz' },
+ { id: 'door', x: 800, y: 470, color: 'oklch(0.78 0.14 145)', name: 'door.steel' },
+ ];
+ @state() private dragging: string | null = null;
+ @state() private selected: string | null = null;
+ private dragOffset = { dx: 0, dy: 0 };
+
+ static styles = css`
+ :host {
+ display: block; height: 100%; width: 100%;
+ background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
+ position: relative; overflow: hidden;
+ border-bottom: 1px solid var(--line);
+ }
+ .grid {
+ position: absolute; inset: 0;
+ background-image:
+ linear-gradient(var(--grid) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid) 1px, transparent 1px);
+ background-size: 32px 32px;
+ pointer-events: none;
+ mask-image: radial-gradient(ellipse at center, black 40%, transparent 100%);
+ }
+ svg { position: absolute; inset: 0; width: 100%; height: 100%; }
+ .stat-card {
+ background: rgba(13,17,23,0.7);
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 8px 12px;
+ font-size: 11px;
+ min-width: 96px;
+ }
+ [data-theme="light"] .stat-card { background: rgba(255,255,255,0.85); }
+ .stat-card .lbl {
+ color: var(--ink-3);
+ text-transform: uppercase; font-weight: 600; letter-spacing: 0.06em; font-size: 9.5px;
+ }
+ .stat-card .val { font-family: var(--mono); font-size: 16px; font-weight: 600; margin-top: 2px; }
+ .stat-card .val.amber { color: var(--accent); }
+ .stat-card .val.cyan { color: var(--accent-2); }
+ .stat-card .val.mint { color: var(--accent-4); }
+ .scene-readout {
+ position: absolute; top: 14px; right: 14px;
+ display: flex; gap: 8px; z-index: 5;
+ }
+ .draggable { cursor: grab; transition: filter 0.15s; }
+ .draggable:hover { filter: brightness(1.15) drop-shadow(0 0 6px currentColor); }
+ .draggable.dragging { cursor: grabbing; filter: brightness(1.25) drop-shadow(0 0 10px currentColor); }
+ .field-line { stroke-dasharray: 4 6; }
+ @keyframes dash { to { stroke-dashoffset: -200; } }
+ .field-line.anim { animation: dash 4s linear infinite; }
+ @keyframes spin {
+ 0% { transform: rotateY(0) rotateX(8deg); }
+ 100% { transform: rotateY(360deg) rotateX(8deg); }
+ }
+ .crystal { transform-origin: center; transform-box: fill-box; }
+ .crystal.anim { animation: spin 12s linear infinite; }
+ .label {
+ font-family: var(--mono); font-size: 11px; fill: var(--ink-2);
+ pointer-events: none;
+ }
+ `;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ effect(() => { lastB.value; bMag.value; fps.value; snr.value; motionReduced.value; this.requestUpdate(); });
+ window.addEventListener('pointermove', this.onPointerMove);
+ window.addEventListener('pointerup', this.onPointerUp);
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ window.removeEventListener('pointermove', this.onPointerMove);
+ window.removeEventListener('pointerup', this.onPointerUp);
+ }
+
+ private onDown = (id: string, e: PointerEvent): void => {
+ e.preventDefault();
+ this.dragging = id;
+ this.selected = id;
+ const item = this.items.find((i) => i.id === id);
+ if (!item) return;
+ const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
+ if (!svgEl) return;
+ const pt = this.toSvg(e, svgEl);
+ this.dragOffset = { dx: pt.x - item.x, dy: pt.y - item.y };
+ };
+
+ private onPointerMove = (e: PointerEvent): void => {
+ if (!this.dragging) return;
+ const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
+ if (!svgEl) return;
+ const pt = this.toSvg(e, svgEl);
+ this.items = this.items.map((it) =>
+ it.id === this.dragging
+ ? { ...it, x: pt.x - this.dragOffset.dx, y: pt.y - this.dragOffset.dy }
+ : it,
+ );
+ };
+
+ private onPointerUp = (): void => { this.dragging = null; };
+
+ private toSvg(e: PointerEvent, svgEl: SVGSVGElement): { x: number; y: number } {
+ const r = svgEl.getBoundingClientRect();
+ const vbX = ((e.clientX - r.left) / r.width) * 1000;
+ const vbY = ((e.clientY - r.top) / r.height) * 600;
+ return { x: vbX, y: vbY };
+ }
+
+ override render() {
+ const b = lastB.value;
+ const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
+ const bMagNT = bMag.value * 1e9;
+ const animClass = motionReduced.value ? '' : 'anim';
+
+ return html`
+
+
+
+
+
+
|B|
+
${bMagNT.toFixed(3)} nT
+
+
+
FPS
+
${fps.value > 0 ? Math.round(fps.value) : '—'}
+
+
+
SNR
+
${snr.value > 0 ? snr.value.toFixed(1) : '—'}
+
+
+ `;
+ }
+}
diff --git a/dashboard/src/components/nv-settings-drawer.ts b/dashboard/src/components/nv-settings-drawer.ts
new file mode 100644
index 00000000..dffa7347
--- /dev/null
+++ b/dashboard/src/components/nv-settings-drawer.ts
@@ -0,0 +1,181 @@
+/* Settings drawer — theme / density / motion / auto-update. */
+import { LitElement, html, css } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { effect } from '@preact/signals-core';
+import { theme, density, motionReduced, autoUpdate, transport, wsUrl } from '../store/appStore';
+
+@customElement('nv-settings-drawer')
+export class NvSettingsDrawer extends LitElement {
+ @state() private open = false;
+
+ static styles = css`
+ :host {
+ position: fixed; top: 0; right: 0; bottom: 0;
+ width: 420px; max-width: 100vw;
+ background: var(--bg-1);
+ border-left: 1px solid var(--line);
+ z-index: 51;
+ transform: translateX(100%);
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ display: flex; flex-direction: column;
+ box-shadow: -20px 0 60px -20px rgba(0,0,0,0.5);
+ }
+ :host([open]) { transform: translateX(0); }
+ .scrim {
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 50;
+ opacity: 0; pointer-events: none;
+ transition: opacity 0.2s;
+ }
+ :host([open]) .scrim { opacity: 1; pointer-events: auto; }
+ .h {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ display: flex; align-items: center; justify-content: space-between;
+ }
+ .h .ttl { font-size: 14px; font-weight: 600; }
+ .body { flex: 1; overflow-y: auto; padding: 16px; }
+ .group { margin-bottom: 22px; }
+ .group h4 {
+ margin: 0 0 10px;
+ font-size: 11px; font-weight: 600;
+ text-transform: uppercase; letter-spacing: 0.08em;
+ color: var(--ink-3);
+ }
+ .row {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--line);
+ }
+ .row:last-child { border-bottom: 0; }
+ .row .lbl { font-size: 13px; }
+ .row .desc { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
+ .row > div:first-child { flex: 1; padding-right: 12px; }
+ .seg {
+ display: inline-flex;
+ background: var(--bg-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 2px;
+ }
+ .seg button {
+ padding: 4px 10px;
+ background: transparent; border: none;
+ border-radius: 6px;
+ font-size: 11.5px; color: var(--ink-3);
+ font-family: var(--mono);
+ cursor: pointer;
+ }
+ .seg button.on { background: var(--bg-1); color: var(--ink); }
+ .toggle {
+ position: relative;
+ width: 36px; height: 20px;
+ background: var(--bg-3);
+ border: 1px solid var(--line-2);
+ border-radius: 999px;
+ cursor: pointer;
+ flex-shrink: 0;
+ }
+ .toggle::after {
+ content: ''; position: absolute;
+ top: 2px; left: 2px;
+ width: 14px; height: 14px;
+ background: var(--ink-3);
+ border-radius: 50%;
+ transition: transform 0.15s, background 0.15s;
+ }
+ .toggle.on { background: var(--accent); border-color: var(--accent); }
+ .toggle.on::after { background: #1a0f00; transform: translateX(16px); }
+ .close {
+ width: 28px; height: 28px;
+ background: transparent; border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--ink-2);
+ }
+ input[type="text"] {
+ background: var(--bg-3);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 6px 10px;
+ color: var(--ink); font-family: var(--mono); font-size: 12px;
+ outline: none;
+ }
+ `;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ effect(() => { theme.value; density.value; motionReduced.value; autoUpdate.value; transport.value; wsUrl.value; this.requestUpdate(); });
+ window.addEventListener('open-settings', () => { this.open = true; this.setAttribute('open', ''); });
+ }
+
+ private close(): void { this.open = false; this.removeAttribute('open'); }
+
+ override render() {
+ return html`
+ this.close()}>
+
+
Settings
+
+
+
+
+
Appearance
+
+
+
+
+
+
+
+
+
+
Density
+
Affects panel padding and font scale.
+
+
+
+
+
+
+
+
+
+
Reduce motion
+
Disable rotating crystal & field-line animation.
+
+
motionReduced.value = !motionReduced.value}>
+
+
+
+
+
Pipeline
+
+
Auto-rerun on edit
+
Restart pipeline when scene/config changes.
+
autoUpdate.value = !autoUpdate.value}>
+
+
+
+
+
Transport
+
+
+
+
+
+
+
+ ${transport.value === 'ws' ? html`
+
+
+
wsUrl.value = (e.target as HTMLInputElement).value} />
+
` : ''}
+
+
+ `;
+ }
+}
diff --git a/dashboard/src/components/nv-sidebar.ts b/dashboard/src/components/nv-sidebar.ts
new file mode 100644
index 00000000..beb14ae8
--- /dev/null
+++ b/dashboard/src/components/nv-sidebar.ts
@@ -0,0 +1,162 @@
+/* Sidebar — Scene panel, NV sensor panel, Tunables, Pipeline diagram. */
+import { LitElement, html, css } from 'lit';
+import { customElement } from 'lit/decorators.js';
+import { effect } from '@preact/signals-core';
+import { fs, fmod, dtMs, noiseEnabled, running } from '../store/appStore';
+
+@customElement('nv-sidebar')
+export class NvSidebar extends LitElement {
+ static styles = css`
+ :host {
+ display: flex; flex-direction: column; gap: 14px;
+ padding: 14px; overflow-y: auto;
+ background: var(--bg-1); border-right: 1px solid var(--line);
+ }
+ .panel {
+ background: var(--bg-2); border: 1px solid var(--line);
+ border-radius: var(--radius); padding: 12px;
+ }
+ .panel-h {
+ display: flex; align-items: center; justify-content: space-between;
+ font-size: 11px; font-weight: 600; color: var(--ink-3);
+ text-transform: uppercase; letter-spacing: 0.08em;
+ margin-bottom: 10px;
+ }
+ .count {
+ background: var(--bg-3); color: var(--ink-2);
+ padding: 1px 6px; border-radius: 999px;
+ font-family: var(--mono); font-size: 10px;
+ text-transform: none; letter-spacing: 0;
+ }
+ .scene-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background 0.15s;
+ border: 1px solid transparent;
+ }
+ .scene-item:hover { background: var(--bg-3); }
+ .scene-item .swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+ .scene-item .name { font-size: 13px; flex: 1; }
+ .scene-item .meta { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); }
+ .field-row {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 6px 0; font-size: 12.5px;
+ border-bottom: 1px solid var(--line);
+ }
+ .field-row:last-child { border-bottom: 0; }
+ .field-row .lbl { color: var(--ink-3); }
+ .field-row .val { font-family: var(--mono); color: var(--ink); font-size: 12px; }
+ .slider-row { padding: 8px 0; border-bottom: 1px solid var(--line); }
+ .slider-row:last-child { border-bottom: 0; padding-bottom: 0; }
+ .slider-row .top { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
+ .slider-row .top .lbl { color: var(--ink-3); }
+ .slider-row .top .val { font-family: var(--mono); color: var(--ink); }
+ input[type="range"] {
+ -webkit-appearance: none; appearance: none;
+ width: 100%; height: 4px;
+ background: var(--bg-3); border-radius: 2px; outline: none;
+ }
+ input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--accent); cursor: pointer;
+ border: 2px solid var(--bg-2);
+ box-shadow: 0 0 0 1px var(--line-2);
+ }
+ .pipeline { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
+ .stage {
+ flex: 1; min-width: 50px;
+ padding: 4px 6px;
+ background: var(--bg-3); border: 1px solid var(--line);
+ border-radius: 6px; font-size: 9.5px; text-align: center;
+ color: var(--ink-2); font-family: var(--mono);
+ }
+ .stage.live { border-color: var(--accent-2); color: var(--accent-2); }
+ .stage-arrow { color: var(--ink-4); font-size: 10px; }
+ `;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ effect(() => { fs.value; fmod.value; dtMs.value; noiseEnabled.value; running.value; this.requestUpdate(); });
+ }
+
+ override render() {
+ return html`
+
+
Scene 4 sources
+
+
+ rebar.steel.coil
+ χ=5000
+
+
+
+ heart_proxy
+ 1e-6 A·m²
+
+
+
+ mains_60Hz
+ 2 A · 60 Hz
+
+
+
+ door.steel
+ eddy
+
+
+
+
+
NV sensor COTS
+
V1 mm³
+
N1e12 NV
+
C0.030
+
T₂*200 ns
+
δB1.18 pT/√Hz
+
+
+
+
Tunables
+
+
Sample rate${(fs.value / 1000).toFixed(1)} kHz
+
fs.value = +(e.target as HTMLInputElement).value} />
+
+
+
Lockin f_mod${(fmod.value / 1000).toFixed(3)} kHz
+
fmod.value = +(e.target as HTMLInputElement).value} />
+
+
+
Integration t${dtMs.value.toFixed(1)} ms
+
dtMs.value = +(e.target as HTMLInputElement).value} />
+
+
+
Shot noise${noiseEnabled.value ? 'ON' : 'OFF'}
+
noiseEnabled.value = (e.target as HTMLInputElement).value === '1'} />
+
+
+
+
+
Pipeline
+
+ scene
+ →
+ B-S
+ →
+ prop
+ →
+ NV
+ →
+ ADC
+ →
+ frame
+
+
+ `;
+ }
+}
diff --git a/dashboard/src/components/nv-toast.ts b/dashboard/src/components/nv-toast.ts
new file mode 100644
index 00000000..7a9a4380
--- /dev/null
+++ b/dashboard/src/components/nv-toast.ts
@@ -0,0 +1,64 @@
+/* Toast notification — shown briefly via window.dispatchEvent('nv-toast', detail). */
+import { LitElement, html, css } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+
+@customElement('nv-toast')
+export class NvToast extends LitElement {
+ @state() private visible = false;
+ @state() private msg = '';
+ @state() private icon = '✓';
+ private timer: number | null = null;
+
+ static styles = css`
+ :host {
+ position: fixed; bottom: 24px; left: 50%;
+ transform: translateX(-50%) translateY(80px);
+ background: var(--bg-2);
+ border: 1px solid var(--line-2);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ font-size: 12.5px;
+ box-shadow: var(--shadow);
+ z-index: 100;
+ opacity: 0; pointer-events: none;
+ transition: opacity 0.2s, transform 0.2s;
+ display: flex; align-items: center; gap: 8px;
+ }
+ :host([visible]) {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ pointer-events: auto;
+ }
+ .icon { color: var(--accent); }
+ `;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ window.addEventListener('nv-toast', this.onToast as EventListener);
+ }
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ window.removeEventListener('nv-toast', this.onToast as EventListener);
+ }
+
+ private onToast = (e: Event): void => {
+ const detail = (e as CustomEvent).detail as { msg?: string; icon?: string };
+ this.msg = detail.msg ?? 'Done';
+ this.icon = detail.icon ?? '✓';
+ this.visible = true;
+ this.setAttribute('visible', '');
+ if (this.timer !== null) window.clearTimeout(this.timer);
+ this.timer = window.setTimeout(() => {
+ this.visible = false;
+ this.removeAttribute('visible');
+ }, 1800);
+ };
+
+ override render() {
+ return html`${this.icon}${this.msg}`;
+ }
+}
+
+export function toast(msg: string, icon = '✓'): void {
+ window.dispatchEvent(new CustomEvent('nv-toast', { detail: { msg, icon } }));
+}
diff --git a/dashboard/src/components/nv-topbar.ts b/dashboard/src/components/nv-topbar.ts
new file mode 100644
index 00000000..ebca0105
--- /dev/null
+++ b/dashboard/src/components/nv-topbar.ts
@@ -0,0 +1,93 @@
+/* Topbar — breadcrumbs, transport pill, FPS pill, seed pill, controls. */
+import { LitElement, html, css } from 'lit';
+import { customElement } from 'lit/decorators.js';
+import { effect } from '@preact/signals-core';
+import {
+ fps, transportLabel, seed, theme, sceneName,
+ running, getClient,
+} from '../store/appStore';
+
+@customElement('nv-topbar')
+export class NvTopbar extends LitElement {
+ static styles = css`
+ :host {
+ display: flex; align-items: center;
+ padding: 0 16px; gap: 12px;
+ background: var(--bg-1);
+ border-bottom: 1px solid var(--line);
+ z-index: 10;
+ }
+ .crumbs { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
+ .crumbs .sep { color: var(--ink-4); }
+ .crumbs .cur { color: var(--ink); font-weight: 500; }
+ .spacer { flex: 1; }
+ .pill {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 5px 10px;
+ background: var(--bg-2); border: 1px solid var(--line);
+ border-radius: 999px;
+ font-size: 12px; color: var(--ink-2);
+ font-family: var(--mono); font-weight: 500;
+ }
+ .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); animation: pulse 2s infinite; }
+ .pill.wasm .dot { background: var(--accent-2); box-shadow: 0 0 6px var(--accent-2); }
+ .pill.seed { color: var(--ink-3); }
+ .pill.seed b { color: var(--accent); font-weight: 600; }
+ button {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 6px 12px;
+ background: var(--bg-2); border: 1px solid var(--line);
+ border-radius: 8px;
+ font-size: 12.5px; font-weight: 500; color: var(--ink);
+ cursor: pointer;
+ transition: all 0.15s;
+ }
+ button:hover { border-color: var(--line-2); background: var(--bg-3); }
+ button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
+ button.primary:hover { filter: brightness(1.08); }
+ button.ghost { background: transparent; }
+ `;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ effect(() => { fps.value; transportLabel.value; seed.value; theme.value; sceneName.value; running.value; this.requestUpdate(); });
+ }
+
+ private async toggleRun(): Promise {
+ const c = getClient(); if (!c) return;
+ if (running.value) { await c.pause(); running.value = false; }
+ else { await c.run(); running.value = true; }
+ }
+ private async reset(): Promise {
+ const c = getClient(); if (!c) return;
+ await c.reset();
+ }
+ private toggleTheme(): void {
+ theme.value = theme.value === 'dark' ? 'light' : 'dark';
+ }
+
+ override render() {
+ const seedHex = seed.value.toString(16).toUpperCase().padStart(8, '0');
+ return html`
+
+ RuView/
+ nvsim/
+ ${sceneName.value}
+
+
+
+
+ ${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'idle'}
+
+ ${transportLabel.value}
+ seed: 0x${seedHex}
+
+
+
+ `;
+ }
+}
diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts
new file mode 100644
index 00000000..4ed86b2e
--- /dev/null
+++ b/dashboard/src/main.ts
@@ -0,0 +1,101 @@
+/* nvsim dashboard entry — boots the WasmClient, mounts . */
+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,
+} 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 m = (await kvGet('motionReduced')) ?? false;
+ 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); });
+
+ // 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}`);
+ }
+})();
diff --git a/dashboard/src/store/appStore.ts b/dashboard/src/store/appStore.ts
new file mode 100644
index 00000000..c8e4d52c
--- /dev/null
+++ b/dashboard/src/store/appStore.ts
@@ -0,0 +1,103 @@
+/* Application-wide reactive state.
+ *
+ * One signal per logical observable; components subscribe to only the
+ * signals they read. Keeps re-renders surgical even at 1 kHz frame rates.
+ * Persistence lives in `persistence.ts`; this module is pure state.
+ */
+import { signal, computed } from '@preact/signals-core';
+import type { NvsimClient, MagFrameRecord, NvsimEvent } from '../transport/NvsimClient';
+
+export type Theme = 'dark' | 'light';
+export type Density = 'comfy' | 'default' | 'compact';
+export type TransportMode = 'wasm' | 'ws';
+
+export const transport = signal('wasm');
+export const wsUrl = signal('');
+export const connected = signal(false);
+export const transportError = signal(null);
+
+export const running = signal(false);
+export const paused = signal(true);
+export const speed = signal(1.0);
+export const t = signal(0); // sim time (s)
+export const framesEmitted = signal(0n);
+
+export const seed = signal(0xCAFEBABEn);
+
+export const fs = signal(10000); // sample rate Hz
+export const fmod = signal(1000); // lockin Hz
+export const dtMs = signal(1.0);
+export const noiseEnabled = signal(true);
+
+export const theme = signal('dark');
+export const density = signal('default');
+export const motionReduced = signal(false);
+export const autoUpdate = signal(true);
+
+export const lastB = signal<[number, number, number]>([0, 0, 0]); // T
+export const bMag = signal(0);
+export const snr = signal(0);
+export const fps = signal(0);
+
+export const witnessHex = signal('');
+export const witnessVerified = signal<'pending' | 'ok' | 'fail' | 'idle'>('idle');
+export const expectedWitness = signal('');
+
+export const lastFrame = signal(null);
+export const traceX = signal([]);
+export const traceY = signal([]);
+export const traceZ = signal([]);
+export const stripBars = signal([]);
+
+export const sceneName = signal('rebar-walkby-01');
+export const sceneJson = signal('');
+
+export const consolePaused = signal(false);
+export const consoleFilter = signal<'all' | 'info' | 'warn' | 'err' | 'dbg' | 'ok'>('all');
+
+export const transportLabel = computed(() =>
+ transport.value === 'wasm' ? 'wasm' : 'ws',
+);
+
+let _client: NvsimClient | null = null;
+export function setClient(c: NvsimClient): void { _client = c; }
+export function getClient(): NvsimClient | null { return _client; }
+
+export interface ConsoleLine {
+ ts: number;
+ level: 'info' | 'warn' | 'err' | 'dbg' | 'ok';
+ msg: string;
+}
+export const consoleLines = signal([]);
+const MAX_LINES = 200;
+
+export function pushLog(level: ConsoleLine['level'], msg: string): void {
+ if (consolePaused.value) return;
+ const next = consoleLines.value.slice();
+ next.push({ ts: Date.now(), level, msg });
+ while (next.length > MAX_LINES) next.shift();
+ consoleLines.value = next;
+}
+
+export function pushTrace(b: [number, number, number]): void {
+ const cap = 200;
+ const x = traceX.value.slice(); x.push(b[0]); if (x.length > cap) x.shift();
+ const y = traceY.value.slice(); y.push(b[1]); if (y.length > cap) y.shift();
+ const z = traceZ.value.slice(); z.push(b[2]); if (z.length > cap) z.shift();
+ traceX.value = x;
+ traceY.value = y;
+ traceZ.value = z;
+}
+
+export function pushStripBar(amp: number): void {
+ const cap = 48;
+ const next = stripBars.value.slice();
+ next.push(Math.max(0, Math.min(1, amp)));
+ while (next.length > cap) next.shift();
+ stripBars.value = next;
+}
+
+export function recordEvent(_ev: NvsimEvent): void {
+ // future: route NvsimEvent into store updates per type. For V1 the
+ // worker pushes B-vector / frame data directly via the data plane.
+}
diff --git a/dashboard/src/store/apps.ts b/dashboard/src/store/apps.ts
new file mode 100644
index 00000000..d9bc161d
--- /dev/null
+++ b/dashboard/src/store/apps.ts
@@ -0,0 +1,309 @@
+/* RuView Edge App Store registry.
+ *
+ * Catalog of every WASM edge module shipping in the workspace plus the
+ * `nvsim` simulator itself. Each entry maps to a hot-loadable algorithm
+ * the dashboard can run in-browser (WASM transport) or push to a real
+ * ESP32-S3 mesh (WS transport, deployed via WASM3 — ADR-040 Tier 3).
+ *
+ * Categories (ADR-041 event-ID ranges):
+ * med 100–199 Medical & health
+ * sec 200–299 Security & safety
+ * bld 300–399 Smart building
+ * ret 400–499 Retail & hospitality
+ * ind 500–599 Industrial
+ * sig 600–619 Signal-processing primitives
+ * lrn 620–639 Online learning
+ * spt 640–659 Spatial / graph
+ * tmp 640–660 Temporal logic / planning
+ * ais 700–719 AI safety
+ * qnt 720–739 Quantum-flavoured signal
+ * aut 740–759 Autonomy / mesh
+ * exo 650–699 Exotic / research
+ * sim — Pipeline simulators (nvsim)
+ *
+ * The `crate` field names the Cargo crate that owns the implementation.
+ * `wasmEdge` apps are compiled out of `wifi-densepose-wasm-edge`;
+ * `nvsim` apps come from `nvsim`. Future apps may target other crates.
+ */
+
+export type AppCategory =
+ | 'sim'
+ | 'med'
+ | 'sec'
+ | 'bld'
+ | 'ret'
+ | 'ind'
+ | 'sig'
+ | 'lrn'
+ | 'spt'
+ | 'tmp'
+ | 'ais'
+ | 'qnt'
+ | 'aut'
+ | 'exo';
+
+export interface AppManifest {
+ /** Stable kebab-case id; matches the wasm-edge module name (e.g. `med_sleep_apnea`). */
+ id: string;
+ /** Human-readable name. */
+ name: string;
+ /** Category short-code. */
+ category: AppCategory;
+ /** Cargo crate the implementation lives in. */
+ crate: 'nvsim' | 'wifi-densepose-wasm-edge' | string;
+ /** One-liner description. */
+ summary: string;
+ /** Optional longer markdown body. */
+ body?: string;
+ /** Numeric event IDs this app emits (i32 codes from `event_types` mod). */
+ events?: number[];
+ /** Compute budget tier the module advertises. S=<5ms, M=<15ms, L=<50ms. */
+ budget?: 'S' | 'M' | 'L';
+ /** Default activation state when listed. */
+ active?: boolean;
+ /** Tags for fuzzy search and filtering. */
+ tags?: string[];
+ /** "Available", "Beta", or "Research" maturity. */
+ status: 'available' | 'beta' | 'research';
+ /** ADR back-reference. */
+ adr?: string;
+}
+
+export const APPS: AppManifest[] = [
+ // ── Pipeline simulators ──────────────────────────────────────────────────
+ {
+ id: 'nvsim',
+ name: 'nvsim — NV-diamond magnetometer',
+ category: 'sim',
+ crate: 'nvsim',
+ summary:
+ 'Deterministic forward simulator: scene → Biot–Savart → NV ensemble → ADC → MagFrame stream + SHA-256 witness.',
+ budget: 'L',
+ active: true,
+ status: 'available',
+ tags: ['quantum', 'magnetometer', 'simulator', 'witness', 'wasm'],
+ adr: 'ADR-089',
+ },
+
+ // ── Core sensing primitives (ADR-014/040 flagship modules) ───────────────
+ {
+ id: 'gesture',
+ name: 'Gesture (DTW)',
+ category: 'sig',
+ crate: 'wifi-densepose-wasm-edge',
+ summary: 'Dynamic-Time-Warping gesture classifier from CSI motion templates.',
+ events: [1],
+ budget: 'M',
+ status: 'available',
+ tags: ['hci', 'csi', 'classifier', 'dtw'],
+ adr: 'ADR-014',
+ },
+ {
+ id: 'coherence',
+ name: 'Coherence gate',
+ category: 'sig',
+ crate: 'wifi-densepose-wasm-edge',
+ summary: 'Z-score coherence scoring + Accept/PredictOnly/Reject/Recalibrate gate.',
+ events: [2],
+ budget: 'S',
+ status: 'available',
+ tags: ['gate', 'csi', 'coherence', 'drift'],
+ adr: 'ADR-029',
+ },
+ {
+ id: 'adversarial',
+ name: 'Adversarial-signal detector',
+ category: 'ais',
+ crate: 'wifi-densepose-wasm-edge',
+ summary:
+ 'Physically-impossible-signal detector — multi-link consistency, used to flag spoofed CSI.',
+ events: [3],
+ budget: 'M',
+ status: 'available',
+ tags: ['security', 'csi', 'spoofing', 'mesh'],
+ adr: 'ADR-032',
+ },
+ {
+ id: 'rvf',
+ name: 'RVF — Rust Verified Feature stream',
+ category: 'sig',
+ crate: 'wifi-densepose-wasm-edge',
+ summary: 'Verified-frame builder with SHA-256 hash + version metadata for the feature stream.',
+ budget: 'S',
+ status: 'available',
+ tags: ['witness', 'csi', 'hash'],
+ adr: 'ADR-040',
+ },
+ {
+ id: 'occupancy',
+ name: 'Occupancy estimator',
+ category: 'bld',
+ crate: 'wifi-densepose-wasm-edge',
+ summary: 'Through-wall presence + person-count via CSI amplitude perturbation.',
+ events: [300, 301, 302],
+ budget: 'S',
+ status: 'available',
+ tags: ['csi', 'building', 'presence'],
+ },
+ {
+ id: 'vital_trend',
+ name: 'Vital-trend monitor',
+ category: 'med',
+ crate: 'wifi-densepose-wasm-edge',
+ summary: 'HR + BR trend tracking with bradycardia/tachycardia/apnea events.',
+ events: [100, 101, 102, 103, 104, 105],
+ budget: 'S',
+ status: 'available',
+ tags: ['medical', 'vitals', 'csi'],
+ adr: 'ADR-021',
+ },
+ {
+ id: 'intrusion',
+ name: 'Intrusion detector',
+ category: 'sec',
+ crate: 'wifi-densepose-wasm-edge',
+ summary: 'Zone-based intrusion alert from CSI motion patterns.',
+ events: [200, 201],
+ budget: 'S',
+ status: 'available',
+ tags: ['security', 'zone', 'csi'],
+ },
+
+ // ── Medical & Health (100-series) ────────────────────────────────────────
+ { id: 'med_sleep_apnea', name: 'Sleep-apnea detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Episodic respiratory pause detection during sleep cycles.', events: [105], budget: 'S', status: 'available', tags: ['medical', 'sleep', 'breathing'] },
+ { id: 'med_cardiac_arrhythmia', name: 'Cardiac arrhythmia', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Beat-to-beat irregularity classifier from cardiac micro-Doppler.', events: [103, 104], budget: 'M', status: 'available', tags: ['medical', 'cardiac', 'arrhythmia'] },
+ { id: 'med_respiratory_distress', name: 'Respiratory distress', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Distress signature: rapid shallow breathing + accessory-muscle motion.', events: [101, 102], budget: 'S', status: 'available', tags: ['medical', 'breathing', 'icu'] },
+ { id: 'med_gait_analysis', name: 'Gait analysis', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Stride length, cadence, asymmetry from through-wall CSI pose tracking.', budget: 'M', status: 'available', tags: ['medical', 'gait', 'pose'] },
+ { id: 'med_seizure_detect', name: 'Seizure detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Tonic-clonic seizure motion signature.', budget: 'M', status: 'beta', tags: ['medical', 'neuro'] },
+
+ // ── Security (200-series) ────────────────────────────────────────────────
+ { id: 'sec_perimeter_breach', name: 'Perimeter breach', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Approach/departure detection at user-defined boundary segments.', events: [210, 211, 212, 213], budget: 'S', status: 'available', tags: ['security', 'perimeter'] },
+ { id: 'sec_weapon_detect', name: 'Metal anomaly / weapon', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Metal-perturbation flag in CSI; potential weapon presence (research).', events: [220, 221, 222], budget: 'M', status: 'research', tags: ['security', 'metal', 'csi'] },
+ { id: 'sec_tailgating', name: 'Tailgating detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Detect 2+ persons crossing a single-passage threshold.', events: [230, 231, 232], budget: 'S', status: 'available', tags: ['security', 'access-control'] },
+ { id: 'sec_loitering', name: 'Loitering detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Stationary occupancy past a configurable dwell threshold.', events: [240, 241, 242], budget: 'S', status: 'available', tags: ['security', 'dwell'] },
+ { id: 'sec_panic_motion', name: 'Panic motion', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'High-energy distress motion: struggle / fleeing pattern.', events: [250, 251, 252], budget: 'S', status: 'beta', tags: ['security', 'distress'] },
+
+ // ── Smart Building (300-series) ──────────────────────────────────────────
+ { id: 'bld_hvac_presence', name: 'HVAC presence', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Occupied/activity-level/departure-countdown for HVAC zones.', events: [310, 311, 312], budget: 'S', status: 'available', tags: ['hvac', 'building', 'energy'] },
+ { id: 'bld_lighting_zones', name: 'Lighting zones', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone light on/dim/off cues from occupancy.', events: [320, 321, 322], budget: 'S', status: 'available', tags: ['lighting', 'building'] },
+ { id: 'bld_elevator_count', name: 'Elevator count', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Person count inside elevator car from CSI.', events: [330], budget: 'S', status: 'available', tags: ['elevator', 'building'] },
+ { id: 'bld_meeting_room', name: 'Meeting-room utilization', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Meeting size + duration analytics for booking systems.', budget: 'S', status: 'available', tags: ['meeting', 'analytics'] },
+ { id: 'bld_energy_audit', name: 'Energy audit', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Continuous occupancy-vs-HVAC-state audit for energy savings.', budget: 'M', status: 'available', tags: ['energy', 'audit'] },
+
+ // ── Retail (400-series) ──────────────────────────────────────────────────
+ { id: 'ret_queue_length', name: 'Queue length', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Live queue-length tracking for checkout / kiosks.', budget: 'S', status: 'available', tags: ['retail', 'queue'] },
+ { id: 'ret_dwell_heatmap', name: 'Dwell heatmap', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone dwell time accumulation; analytics-only export.', budget: 'M', status: 'available', tags: ['retail', 'heatmap'] },
+ { id: 'ret_customer_flow', name: 'Customer flow', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Origin-destination flow graph through a store layout.', budget: 'M', status: 'available', tags: ['retail', 'flow'] },
+ { id: 'ret_table_turnover', name: 'Table turnover', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Restaurant table seat / vacate transitions.', budget: 'S', status: 'available', tags: ['retail', 'restaurant'] },
+ { id: 'ret_shelf_engagement', name: 'Shelf engagement', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Reach-to-shelf gestures and dwell at product zones.', budget: 'M', status: 'available', tags: ['retail', 'shelf'] },
+
+ // ── Industrial (500-series) ──────────────────────────────────────────────
+ { id: 'ind_forklift_proximity', name: 'Forklift proximity', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Worker-near-forklift safety alert.', budget: 'S', status: 'available', tags: ['industrial', 'safety'] },
+ { id: 'ind_confined_space', name: 'Confined-space monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Last-person-out detection + presence audit for OSHA confined-space entries.', budget: 'S', status: 'available', tags: ['industrial', 'osha'] },
+ { id: 'ind_clean_room', name: 'Clean-room PPE / motion', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Motion patterns consistent with proper PPE-clad movement.', budget: 'M', status: 'beta', tags: ['industrial', 'cleanroom'] },
+ { id: 'ind_livestock_monitor', name: 'Livestock monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Vital-sign + activity tracking for stall-bound livestock.', budget: 'M', status: 'beta', tags: ['agriculture', 'livestock'] },
+ { id: 'ind_structural_vibration', name: 'Structural vibration', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Building/equipment micro-vibration via CSI phase derivative.', budget: 'M', status: 'research', tags: ['industrial', 'vibration'] },
+
+ // ── Signal primitives (600-series) ───────────────────────────────────────
+ { id: 'sig_coherence_gate', name: 'Coherence gate (extended)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Hysteresis + multi-state coherence gate driving downstream apps.', budget: 'S', status: 'available', tags: ['gate', 'csi'] },
+ { id: 'sig_flash_attention', name: 'Flash attention (CSI)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-friendly attention block for CSI subcarrier weighting.', budget: 'M', status: 'beta', tags: ['attention', 'csi'] },
+ { id: 'sig_temporal_compress', name: 'Temporal-tensor compress', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'RuVector temporal-tensor compression on the CSI buffer.', budget: 'M', status: 'available', tags: ['compress', 'tensor'] },
+ { id: 'sig_sparse_recovery', name: 'Sparse recovery', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: '114→56 subcarrier sparse interpolation via L1 solver.', budget: 'M', status: 'available', tags: ['sparse', 'csi'] },
+ { id: 'sig_mincut_person_match', name: 'Mincut person-match', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Min-cut person assignment across multistatic frames.', budget: 'M', status: 'available', tags: ['mincut', 'matching'] },
+ { id: 'sig_optimal_transport', name: 'Optimal transport', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'OT-based feature alignment between mesh nodes.', budget: 'M', status: 'beta', tags: ['ot', 'alignment'] },
+
+ // ── Online learning ──────────────────────────────────────────────────────
+ { id: 'lrn_dtw_gesture_learn', name: 'DTW gesture learn', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'On-device template learning for personalized gesture libraries.', budget: 'M', status: 'beta', tags: ['lifelong', 'gesture'] },
+ { id: 'lrn_anomaly_attractor', name: 'Anomaly attractor', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Novelty detector with dynamic-attractor recall.', budget: 'M', status: 'research', tags: ['novelty', 'lifelong'] },
+ { id: 'lrn_meta_adapt', name: 'Meta-adapt', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Meta-learning adapter for fast site-to-site transfer.', budget: 'L', status: 'research', tags: ['meta-learning'] },
+ { id: 'lrn_ewc_lifelong', name: 'EWC++ lifelong', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Elastic-weight-consolidation gate to avoid catastrophic forgetting.', budget: 'M', status: 'beta', tags: ['lifelong', 'ewc'] },
+
+ // ── Spatial / graph ──────────────────────────────────────────────────────
+ { id: 'spt_pagerank_influence', name: 'PageRank influence', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Graph-influence ranking on the multistatic mesh.', budget: 'M', status: 'beta', tags: ['graph', 'pagerank'] },
+ { id: 'spt_micro_hnsw', name: 'µHNSW vector index', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Tiny HNSW index for AETHER re-ID embeddings on-device.', budget: 'M', status: 'available', tags: ['hnsw', 'reid'] },
+ { id: 'spt_spiking_tracker', name: 'Spiking tracker', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Spiking-network multi-target tracker.', budget: 'L', status: 'research', tags: ['snn', 'tracker'] },
+
+ // ── Temporal / planning ──────────────────────────────────────────────────
+ { id: 'tmp_pattern_sequence', name: 'Pattern sequence', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Sequence-of-events pattern matcher (e.g. ingress→linger→egress).', budget: 'M', status: 'available', tags: ['temporal', 'pattern'] },
+ { id: 'tmp_temporal_logic_guard', name: 'Temporal logic guard', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'LTL/MTL safety-property guard over event streams.', budget: 'M', status: 'beta', tags: ['ltl', 'safety'] },
+ { id: 'tmp_goap_autonomy', name: 'GOAP autonomy', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Goal-oriented action planning for adaptive routines.', budget: 'L', status: 'research', tags: ['planning', 'autonomy'] },
+
+ // ── AI safety ────────────────────────────────────────────────────────────
+ { id: 'ais_prompt_shield', name: 'Prompt shield', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-side LLM prompt-injection guard for on-device assistants.', budget: 'M', status: 'beta', tags: ['security', 'llm'] },
+ { id: 'ais_behavioral_profiler', name: 'Behavioral profiler', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Anomalous-behaviour profiler (drift in motion habits).', budget: 'M', status: 'beta', tags: ['anomaly', 'behaviour'] },
+
+ // ── Quantum-flavoured ────────────────────────────────────────────────────
+ { id: 'qnt_quantum_coherence', name: 'Quantum coherence', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Coherence diagnostics adapted for quantum-sensor signals.', budget: 'M', status: 'research', tags: ['quantum', 'coherence'] },
+ { id: 'qnt_interference_search', name: 'Interference search', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Interferometric anomaly search across mesh viewpoints.', budget: 'L', status: 'research', tags: ['quantum', 'interference'] },
+
+ // ── Autonomy / mesh ──────────────────────────────────────────────────────
+ { id: 'aut_psycho_symbolic', name: 'Psycho-symbolic agent', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Symbolic-rule + neural-feature hybrid for low-power autonomy loops.', budget: 'L', status: 'research', tags: ['autonomy', 'symbolic'] },
+ { id: 'aut_self_healing_mesh', name: 'Self-healing mesh', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Mesh-topology repair with per-node health gossip.', budget: 'M', status: 'beta', tags: ['mesh', 'health'] },
+
+ // ── Exotic / Research (650-series) ───────────────────────────────────────
+ { id: 'exo_ghost_hunter', name: 'Ghost hunter (anomaly)', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Empty-room CSI anomaly detector — impulsive/periodic/drift/random + hidden-presence sub-detector.', events: [650, 651, 652, 653], budget: 'S', status: 'available', tags: ['anomaly', 'paranormal', 'csi'], adr: 'ADR-041' },
+ { id: 'exo_breathing_sync', name: 'Breathing sync', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Multi-person breathing synchrony analytics.', budget: 'M', status: 'beta', tags: ['breathing', 'sync'] },
+ { id: 'exo_dream_stage', name: 'Dream-stage classifier', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'NREM/REM stage classification from breathing + micro-motion.', budget: 'M', status: 'research', tags: ['sleep', 'rem'] },
+ { id: 'exo_emotion_detect', name: 'Emotion detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Coarse arousal/valence from breathing + heart-rate variability.', budget: 'M', status: 'research', tags: ['affect'] },
+ { id: 'exo_gesture_language', name: 'Gesture language', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Sign-language pattern recognition.', budget: 'L', status: 'research', tags: ['hci', 'sign'] },
+ { id: 'exo_happiness_score', name: 'Happiness score', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Aggregate well-being score from co-occupancy + activity dynamics.', budget: 'M', status: 'research', tags: ['affect', 'wellbeing'] },
+ { id: 'exo_hyperbolic_space', name: 'Hyperbolic space embed', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Hyperbolic embeddings for hierarchical scene structure.', budget: 'L', status: 'research', tags: ['embedding', 'hyperbolic'] },
+ { id: 'exo_music_conductor', name: 'Music conductor', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Map gesture energy to MIDI tempo/dynamics.', budget: 'M', status: 'research', tags: ['midi', 'art'] },
+ { id: 'exo_plant_growth', name: 'Plant-growth tracker', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Slow CSI drift tracking for greenhouse foliage growth.', budget: 'L', status: 'research', tags: ['agriculture'] },
+ { id: 'exo_rain_detect', name: 'Rain detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Outdoor CSI signature of rainfall.', budget: 'M', status: 'research', tags: ['weather'] },
+ { id: 'exo_time_crystal', name: 'Time-crystal periodicity', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Periodicity diagnostics with anti-aliasing harmonics.', budget: 'M', status: 'research', tags: ['periodicity'] },
+];
+
+export const CATEGORIES: Record = {
+ sim: { label: 'Simulators', color: 'oklch(0.78 0.14 70)', range: '—' },
+ med: { label: 'Medical & Health', color: 'oklch(0.65 0.22 25)', range: '100–199' },
+ sec: { label: 'Security & Safety', color: 'oklch(0.7 0.18 35)', range: '200–299' },
+ bld: { label: 'Smart Building', color: 'oklch(0.78 0.12 195)', range: '300–399' },
+ ret: { label: 'Retail & Hospitality', color: 'oklch(0.78 0.14 145)', range: '400–499' },
+ ind: { label: 'Industrial', color: 'oklch(0.72 0.18 330)', range: '500–599' },
+ sig: { label: 'Signal Processing', color: 'oklch(0.78 0.14 70)', range: '600–619' },
+ lrn: { label: 'Online Learning', color: 'oklch(0.78 0.12 260)', range: '620–639' },
+ spt: { label: 'Spatial / Graph', color: 'oklch(0.7 0.18 100)', range: '640–659' },
+ tmp: { label: 'Temporal / Planning', color: 'oklch(0.7 0.16 50)', range: '660–679' },
+ ais: { label: 'AI Safety', color: 'oklch(0.65 0.22 25)', range: '700–719' },
+ qnt: { label: 'Quantum', color: 'oklch(0.72 0.18 290)', range: '720–739' },
+ aut: { label: 'Autonomy', color: 'oklch(0.78 0.14 145)', range: '740–759' },
+ exo: { label: 'Exotic / Research', color: 'oklch(0.72 0.18 330)', range: '650–699' },
+};
+
+export interface AppActivation {
+ id: string;
+ /** Active in the current session. */
+ active: boolean;
+ /** Last activation timestamp. */
+ lastActivatedAt?: number;
+ /** Last event count seen (for the cards' counter). */
+ eventCount?: number;
+}
+
+export function defaultActivations(): AppActivation[] {
+ return APPS.map((a) => ({ id: a.id, active: a.active === true, eventCount: 0 }));
+}
+
+export function appsByCategory(): Record {
+ const map = {} as Record;
+ for (const c of Object.keys(CATEGORIES) as AppCategory[]) map[c] = [];
+ for (const a of APPS) map[a.category].push(a);
+ return map;
+}
+
+export function findApp(id: string): AppManifest | undefined {
+ return APPS.find((a) => a.id === id);
+}
+
+export function fuzzyMatch(query: string, app: AppManifest): number {
+ if (!query) return 1;
+ const q = query.toLowerCase();
+ let score = 0;
+ if (app.id.toLowerCase().includes(q)) score += 3;
+ if (app.name.toLowerCase().includes(q)) score += 3;
+ if (app.summary.toLowerCase().includes(q)) score += 1;
+ if (app.tags?.some((t) => t.toLowerCase().includes(q))) score += 2;
+ if (app.category === q) score += 5;
+ return score;
+}
diff --git a/dashboard/src/store/persistence.ts b/dashboard/src/store/persistence.ts
new file mode 100644
index 00000000..375fa8b5
--- /dev/null
+++ b/dashboard/src/store/persistence.ts
@@ -0,0 +1,52 @@
+/* IndexedDB-backed persistence for settings and saved scenes.
+ * Mirrors the mockup's `nvsim/kv` store. */
+
+const DB_NAME = 'nvsim';
+const DB_VER = 1;
+const STORE = 'kv';
+
+let dbPromise: Promise | null = null;
+
+function openDb(): Promise {
+ if (dbPromise) return dbPromise;
+ dbPromise = new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VER);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+ return dbPromise;
+}
+
+export async function kvGet(key: string): Promise {
+ const db = await openDb();
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readonly');
+ const r = tx.objectStore(STORE).get(key);
+ r.onsuccess = () => resolve(r.result as T | undefined);
+ r.onerror = () => reject(r.error);
+ });
+}
+
+export async function kvSet(key: string, value: unknown): Promise {
+ const db = await openDb();
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).put(value, key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+export async function kvDelete(key: string): Promise {
+ const db = await openDb();
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).delete(key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
diff --git a/dashboard/src/transport/NvsimClient.ts b/dashboard/src/transport/NvsimClient.ts
new file mode 100644
index 00000000..1bd07846
--- /dev/null
+++ b/dashboard/src/transport/NvsimClient.ts
@@ -0,0 +1,127 @@
+/* Common NvsimClient interface — both WasmClient and WsClient implement it.
+ * Dashboard binds to this interface and never to a concrete client.
+ * Aligns with ADR-092 §5.2.
+ */
+
+export interface PipelineConfigJson {
+ digitiser?: {
+ f_s_hz: number;
+ f_mod_hz: number;
+ lp_cutoff_hz: number;
+ };
+ sensor?: {
+ n_centers: number;
+ contrast: number;
+ t2_star_s: number;
+ shot_noise_disabled?: boolean;
+ };
+ dt_s?: number | null;
+}
+
+export interface SceneJson {
+ dipoles: { position: [number, number, number]; moment: [number, number, number] }[];
+ loops: {
+ centre: [number, number, number];
+ normal: [number, number, number];
+ radius: number;
+ current: number;
+ n_segments: number;
+ }[];
+ ferrous: {
+ position: [number, number, number];
+ volume: number;
+ susceptibility: number;
+ }[];
+ eddy: unknown[];
+ sensors: [number, number, number][];
+ ambient_field: [number, number, number];
+}
+
+export interface MagFrameRecord {
+ magic: number;
+ version: number;
+ flags: number;
+ sensorId: number;
+ tUs: bigint;
+ bPt: [number, number, number];
+ sigmaPt: [number, number, number];
+ noiseFloorPtSqrtHz: number;
+ temperatureK: number;
+ raw: Uint8Array;
+}
+
+export interface MagFrameBatch {
+ frames: MagFrameRecord[];
+ bytes: Uint8Array;
+}
+
+export type NvsimEvent =
+ | { type: 'log'; level: 'info' | 'warn' | 'err' | 'dbg' | 'ok'; msg: string }
+ | { type: 'witness'; hex: string }
+ | { type: 'fps'; value: number }
+ | { type: 'state'; running: boolean; t: number; framesEmitted: number };
+
+export interface RunOpts { frames?: number }
+
+export interface NvsimClient {
+ loadScene(scene: SceneJson): Promise;
+ setConfig(cfg: PipelineConfigJson): Promise;
+ setSeed(seed: bigint): Promise;
+ reset(): Promise;
+ run(opts?: RunOpts): Promise;
+ pause(): Promise;
+ step(direction: 'fwd' | 'back', dtMs: number): Promise;
+
+ onFrames(cb: (batch: MagFrameBatch) => void): void;
+ onEvent(cb: (ev: NvsimEvent) => void): void;
+
+ generateWitness(samples: number): Promise;
+ verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }>;
+ exportProofBundle(): Promise;
+
+ buildId(): Promise;
+ close(): Promise;
+}
+
+/** Parse one MagFrame from a 60-byte slice. Layout matches `nvsim::frame`. */
+export function parseMagFrame(view: DataView, offset: number, raw: Uint8Array): MagFrameRecord {
+ // v1 layout: magic(u32) | version(u16) | flags(u16) | sensor_id(u16) | _reserved(u16) |
+ // t_us(u64) | b_pt[3](f32) | sigma_pt[3](f32) | noise_floor_pt_sqrt_hz(f32) |
+ // temperature_k(f32) — 60 bytes total. All little-endian.
+ const magic = view.getUint32(offset + 0, true);
+ const version = view.getUint16(offset + 4, true);
+ const flags = view.getUint16(offset + 6, true);
+ const sensorId = view.getUint16(offset + 8, true);
+ // skip 2 bytes reserved at offset+10
+ const tUs = view.getBigUint64(offset + 12, true);
+ const bx = view.getFloat32(offset + 20, true);
+ const by = view.getFloat32(offset + 24, true);
+ const bz = view.getFloat32(offset + 28, true);
+ const sx = view.getFloat32(offset + 32, true);
+ const sy = view.getFloat32(offset + 36, true);
+ const sz = view.getFloat32(offset + 40, true);
+ const noiseFloorPtSqrtHz = view.getFloat32(offset + 44, true);
+ const temperatureK = view.getFloat32(offset + 48, true);
+ return {
+ magic,
+ version,
+ flags,
+ sensorId,
+ tUs,
+ bPt: [bx, by, bz],
+ sigmaPt: [sx, sy, sz],
+ noiseFloorPtSqrtHz,
+ temperatureK,
+ raw: raw.subarray(offset, offset + 60),
+ };
+}
+
+export function parseFrameBatch(bytes: Uint8Array): MagFrameRecord[] {
+ const frameSize = 60;
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const out: MagFrameRecord[] = [];
+ for (let off = 0; off + frameSize <= bytes.byteLength; off += frameSize) {
+ out.push(parseMagFrame(view, off, bytes));
+ }
+ return out;
+}
diff --git a/dashboard/src/transport/WasmClient.ts b/dashboard/src/transport/WasmClient.ts
new file mode 100644
index 00000000..b3e63a28
--- /dev/null
+++ b/dashboard/src/transport/WasmClient.ts
@@ -0,0 +1,183 @@
+/* Default `NvsimClient` implementation. Talks to the Web Worker that
+ * hosts the nvsim WASM module. ADR-092 §5.4 + §6.3. */
+
+import {
+ type NvsimClient,
+ type SceneJson,
+ type PipelineConfigJson,
+ type RunOpts,
+ type MagFrameBatch,
+ type NvsimEvent,
+ parseFrameBatch,
+} from './NvsimClient';
+
+interface PendingRequest {
+ resolve: (v: T) => void;
+ reject: (err: Error) => void;
+}
+
+export interface WasmBootInfo {
+ buildVersion: string;
+ frameMagic: number;
+ frameBytes: number;
+ expectedWitnessHex: string;
+}
+
+export class WasmClient implements NvsimClient {
+ private worker: Worker;
+ private nextId = 1;
+ private pending = new Map>();
+ private frameSubs = new Set<(b: MagFrameBatch) => void>();
+ private eventSubs = new Set<(e: NvsimEvent) => void>();
+ private bootInfo: WasmBootInfo | null = null;
+
+ constructor() {
+ this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
+ this.worker.addEventListener('message', (ev) => this.onMessage(ev));
+ this.worker.addEventListener('error', (e) =>
+ this.eventSubs.forEach((s) => s({ type: 'log', level: 'err', msg: String(e.message) })),
+ );
+ }
+
+ private onMessage(ev: MessageEvent): void {
+ const m = ev.data as { type: string; id?: number; [k: string]: unknown };
+ if (m.type === 'frames') {
+ const buf = m.batch as ArrayBuffer;
+ const bytes = new Uint8Array(buf);
+ const frames = parseFrameBatch(bytes);
+ const batch: MagFrameBatch = { frames, bytes };
+ this.frameSubs.forEach((s) => s(batch));
+ const fps = m.fps as number;
+ if (fps > 0) {
+ this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
+ }
+ return;
+ }
+ if (m.type === 'state') {
+ this.eventSubs.forEach((s) =>
+ s({
+ type: 'state',
+ running: Boolean(m.running),
+ t: 0,
+ framesEmitted: Number(m.framesEmitted ?? 0),
+ }),
+ );
+ return;
+ }
+ if (m.type === 'ready') {
+ return;
+ }
+ if (m.type === 'err' && m.id == null) {
+ this.eventSubs.forEach((s) =>
+ s({ type: 'log', level: 'err', msg: String(m.msg) }),
+ );
+ return;
+ }
+ if (typeof m.id === 'number' && this.pending.has(m.id)) {
+ const p = this.pending.get(m.id)!;
+ this.pending.delete(m.id);
+ if (m.type === 'err') p.reject(new Error(String(m.msg)));
+ else p.resolve(m);
+ }
+ }
+
+ private rpc(msg: Record, transfer: Transferable[] = []): Promise {
+ const id = this.nextId++;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
+ this.worker.postMessage({ ...msg, id }, transfer);
+ });
+ }
+
+ async boot(): Promise {
+ if (this.bootInfo) return this.bootInfo;
+ const r = await this.rpc<{ buildVersion: string; frameMagic: number; frameBytes: number; expectedWitnessHex: string }>(
+ { type: 'boot' },
+ );
+ this.bootInfo = {
+ buildVersion: r.buildVersion,
+ frameMagic: r.frameMagic,
+ frameBytes: r.frameBytes,
+ expectedWitnessHex: r.expectedWitnessHex,
+ };
+ return this.bootInfo;
+ }
+
+ async loadScene(scene: SceneJson): Promise {
+ await this.rpc({ type: 'setScene', json: JSON.stringify(scene) });
+ }
+
+ async setConfig(cfg: PipelineConfigJson): Promise {
+ await this.rpc({ type: 'setConfig', json: JSON.stringify(cfg) });
+ }
+
+ async setSeed(seed: bigint): Promise {
+ await this.rpc({ type: 'setSeed', seed: Number(seed & 0xFFFFFFFFn) });
+ }
+
+ async reset(): Promise {
+ await this.rpc({ type: 'reset' });
+ }
+
+ async run(_opts?: RunOpts): Promise {
+ await this.rpc({ type: 'run' });
+ }
+
+ async pause(): Promise {
+ await this.rpc({ type: 'pause' });
+ }
+
+ async step(_direction: 'fwd' | 'back', _dtMs: number): Promise {
+ await this.rpc({ type: 'step' });
+ }
+
+ onFrames(cb: (batch: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
+ onEvent(cb: (ev: NvsimEvent) => void): void { this.eventSubs.add(cb); }
+
+ async generateWitness(samples: number): Promise {
+ const r = await this.rpc<{ witness: ArrayBuffer; hex: string }>({ type: 'witnessGenerate', samples });
+ return new Uint8Array(r.witness);
+ }
+
+ async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
+ const buf = expected.slice().buffer;
+ const r = await this.rpc<{ ok: boolean; actual: ArrayBuffer; actualHex: string }>(
+ { type: 'witnessVerify', samples: 256, expected: buf },
+ [buf],
+ );
+ if (r.ok) return { ok: true };
+ return { ok: false, actual: new Uint8Array(r.actual) };
+ }
+
+ async exportProofBundle(): Promise {
+ // Bundle = REFERENCE_SCENE_JSON + computed witness hex + version. Wraps
+ // the same artifacts `Proof::generate` produces natively. ADR-092 §6.1.
+ const w = await this.generateWitness(256);
+ const hex = Array.from(w).map((b) => b.toString(16).padStart(2, '0')).join('');
+ const info = this.bootInfo ?? (await this.boot());
+ const manifest = JSON.stringify(
+ {
+ kind: 'nvsim-proof-bundle',
+ version: info.buildVersion,
+ seed: '0x0000002A',
+ nSamples: 256,
+ witness: hex,
+ expected: info.expectedWitnessHex,
+ ok: hex === info.expectedWitnessHex,
+ ts: new Date().toISOString(),
+ },
+ null,
+ 2,
+ );
+ return new Blob([manifest], { type: 'application/json' });
+ }
+
+ async buildId(): Promise {
+ const r = await this.rpc<{ buildId: string }>({ type: 'buildId' });
+ return r.buildId;
+ }
+
+ async close(): Promise {
+ this.worker.terminate();
+ }
+}
diff --git a/dashboard/src/transport/worker.ts b/dashboard/src/transport/worker.ts
new file mode 100644
index 00000000..c169bbf2
--- /dev/null
+++ b/dashboard/src/transport/worker.ts
@@ -0,0 +1,250 @@
+/* Web Worker hosting the nvsim WASM module.
+ *
+ * Boots `/nvsim-pkg/nvsim.js`, instantiates `WasmPipeline`, then
+ * postMessage-RPCs with the main thread. Frame batches are returned
+ * as `ArrayBuffer` transfers so we don't pay a copy on the hot path.
+ *
+ * ADR-092 §5.4.
+ */
+
+///
+
+const ws = self as unknown as DedicatedWorkerGlobalScope;
+
+interface WasmPipelineApi {
+ run(n: number): Uint8Array;
+ runWithWitness(n: number): { frames: Uint8Array; witness: Uint8Array; frameCount: number };
+ free?: () => void;
+}
+type WasmPipelineCtor = new (sceneJson: string, configJson: string, seed: number) => WasmPipelineApi;
+type WasmPipelineStatic = WasmPipelineCtor & {
+ buildVersion(): string;
+ frameMagic(): number;
+ frameBytes(): number;
+};
+
+interface NvsimPkg {
+ default: (input?: unknown) => Promise;
+ WasmPipeline: WasmPipelineStatic;
+ referenceSceneJson: () => string;
+ expectedReferenceWitnessHex: () => string;
+ hexWitness: (b: Uint8Array) => string;
+ referenceWitness: () => Uint8Array;
+}
+
+let _WasmPipeline!: WasmPipelineStatic;
+let referenceSceneJson!: () => string;
+let expectedReferenceWitnessHex!: () => string;
+let hexWitness!: (b: Uint8Array) => string;
+let referenceWitness!: () => Uint8Array;
+
+async function loadPkg(): Promise {
+ const baseHref = `${ws.location.origin}/`;
+ const pkgUrl = new URL('nvsim-pkg/nvsim.js', baseHref).href;
+ const pkg = (await import(/* @vite-ignore */ pkgUrl)) as NvsimPkg;
+ await pkg.default();
+ _WasmPipeline = pkg.WasmPipeline;
+ referenceSceneJson = pkg.referenceSceneJson;
+ expectedReferenceWitnessHex = pkg.expectedReferenceWitnessHex;
+ hexWitness = pkg.hexWitness;
+ referenceWitness = pkg.referenceWitness;
+}
+
+let pipeline: WasmPipelineApi | null = null;
+let configJson = '';
+let sceneJson = '';
+let seed = BigInt(0xCAFEBABE);
+
+let running = false;
+let timer: number | null = null;
+let framesEmitted = 0;
+let tStart = 0;
+
+function ensureRebuild(): void {
+ if (!sceneJson) sceneJson = referenceSceneJson();
+ if (!configJson) {
+ configJson = JSON.stringify({
+ digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
+ sensor: {
+ gamma_fwhm_hz: 1.0e6,
+ t1_s: 5.0e-3,
+ t2_s: 1.0e-6,
+ t2_star_s: 200e-9,
+ contrast: 0.03,
+ n_spins: 1.0e12,
+ shot_noise_disabled: false,
+ },
+ dt_s: null,
+ });
+ }
+ pipeline?.free?.();
+ pipeline = new _WasmPipeline(sceneJson, configJson, Number(seed & 0xFFFFFFFFn));
+}
+
+function post(msg: unknown, transfer: Transferable[] = []): void {
+ // postMessage Transferable overload: pass transfer list as 2nd arg
+ (ws.postMessage as (msg: unknown, t: Transferable[]) => void)(msg, transfer);
+}
+
+function startTimer(): void {
+ if (timer !== null) return;
+ tStart = performance.now();
+ framesEmitted = 0;
+ const tick = (): void => {
+ if (!running || !pipeline) return;
+ // Per-tick: simulate 32 frames; push as one batch.
+ const n = 32;
+ const bytes = pipeline.run(n);
+ framesEmitted += n;
+ const elapsed = (performance.now() - tStart) / 1000;
+ const fps = elapsed > 0 ? framesEmitted / elapsed : 0;
+ post(
+ { type: 'frames', batch: bytes.buffer, count: n, fps, framesEmitted },
+ [bytes.buffer],
+ );
+ timer = ws.setTimeout(tick, 16);
+ };
+ timer = ws.setTimeout(tick, 0);
+}
+
+function stopTimer(): void {
+ if (timer !== null) {
+ ws.clearTimeout(timer);
+ timer = null;
+ }
+}
+
+ws.addEventListener('message', async (ev: MessageEvent): Promise => {
+ const m = ev.data as { type: string; id?: number; [k: string]: unknown };
+ try {
+ switch (m.type) {
+ case 'boot': {
+ await loadPkg();
+ ensureRebuild();
+ post({
+ type: 'booted',
+ id: m.id,
+ buildVersion: _WasmPipeline.buildVersion(),
+ frameMagic: _WasmPipeline.frameMagic(),
+ frameBytes: _WasmPipeline.frameBytes(),
+ expectedWitnessHex: expectedReferenceWitnessHex(),
+ });
+ break;
+ }
+ case 'setScene': {
+ sceneJson = m.json as string;
+ ensureRebuild();
+ post({ type: 'ack', id: m.id });
+ break;
+ }
+ case 'setConfig': {
+ configJson = m.json as string;
+ ensureRebuild();
+ post({ type: 'ack', id: m.id });
+ break;
+ }
+ case 'setSeed': {
+ seed = BigInt(m.seed as string | number | bigint);
+ ensureRebuild();
+ post({ type: 'ack', id: m.id });
+ break;
+ }
+ case 'reset': {
+ stopTimer();
+ running = false;
+ ensureRebuild();
+ framesEmitted = 0;
+ post({ type: 'ack', id: m.id });
+ post({ type: 'state', running: false, framesEmitted });
+ break;
+ }
+ case 'run': {
+ if (!pipeline) ensureRebuild();
+ running = true;
+ startTimer();
+ post({ type: 'ack', id: m.id });
+ post({ type: 'state', running: true, framesEmitted });
+ break;
+ }
+ case 'pause': {
+ running = false;
+ stopTimer();
+ post({ type: 'ack', id: m.id });
+ post({ type: 'state', running: false, framesEmitted });
+ break;
+ }
+ case 'step': {
+ if (!pipeline) ensureRebuild();
+ const bytes = pipeline!.run(1);
+ framesEmitted += 1;
+ post(
+ { type: 'frames', batch: bytes.buffer, count: 1, fps: 0, framesEmitted },
+ [bytes.buffer],
+ );
+ post({ type: 'ack', id: m.id });
+ break;
+ }
+ case 'witnessGenerate': {
+ if (!pipeline) ensureRebuild();
+ const samples = (m.samples as number) ?? 256;
+ const result = pipeline!.runWithWitness(samples) as {
+ frames: Uint8Array;
+ witness: Uint8Array;
+ frameCount: number;
+ };
+ const hex = hexWitness(result.witness);
+ post(
+ {
+ type: 'witness',
+ id: m.id,
+ witness: result.witness.buffer,
+ hex,
+ frameCount: result.frameCount,
+ },
+ [result.witness.buffer],
+ );
+ break;
+ }
+ case 'witnessVerify': {
+ // Verify always runs the *canonical* reference scene at seed=42, N=256
+ // so the witness matches Proof::EXPECTED_WITNESS_HEX byte-for-byte.
+ // The user's working scene/config/seed don't affect the witness.
+ const expectedBuf = m.expected as ArrayBuffer;
+ const expected = new Uint8Array(expectedBuf);
+ const actual = referenceWitness();
+ let ok = actual.length === expected.length;
+ if (ok) {
+ for (let i = 0; i < expected.length; i++) {
+ if (actual[i] !== expected[i]) { ok = false; break; }
+ }
+ }
+ const actualBuf = actual.slice().buffer;
+ post(
+ {
+ type: 'verify',
+ id: m.id,
+ ok,
+ actual: actualBuf,
+ actualHex: hexWitness(actual),
+ },
+ [actualBuf],
+ );
+ break;
+ }
+ case 'buildId': {
+ post({
+ type: 'buildId',
+ id: m.id,
+ buildId: `nvsim@${_WasmPipeline.buildVersion()}`,
+ });
+ break;
+ }
+ default:
+ post({ type: 'err', id: m.id, msg: `unknown op ${m.type}` });
+ }
+ } catch (e) {
+ post({ type: 'err', id: m.id, msg: (e as Error).message ?? String(e) });
+ }
+});
+
+post({ type: 'ready' });
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 00000000..de228948
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noImplicitOverride": false,
+ "noFallthroughCasesInSwitch": true,
+ "exactOptionalPropertyTypes": false,
+ "useDefineForClassFields": false,
+ "experimentalDecorators": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*", "vite.config.ts"],
+ "exclude": ["node_modules", "dist", "public/nvsim-pkg"]
+}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
new file mode 100644
index 00000000..84d02c01
--- /dev/null
+++ b/dashboard/vite.config.ts
@@ -0,0 +1,41 @@
+import { defineConfig } from 'vite';
+
+// Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker.
+// Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable
+// via NVSIM_BASE so local dev (npm run dev) stays at "/".
+const base = (globalThis as { process?: { env?: { NVSIM_BASE?: string } } }).process?.env?.NVSIM_BASE ?? '/';
+
+export default defineConfig({
+ base,
+ publicDir: 'public',
+ worker: {
+ format: 'es',
+ },
+ build: {
+ target: 'es2022',
+ sourcemap: true,
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ lit: ['lit'],
+ signals: ['@preact/signals-core'],
+ },
+ },
+ },
+ },
+ server: {
+ port: 5173,
+ strictPort: true,
+ fs: {
+ // wasm-pack output sits in public/nvsim-pkg; vite already serves it,
+ // but allow fs reads from the workspace root for HMR convenience.
+ allow: ['..', '.'],
+ },
+ headers: {
+ // SAB ring buffer is opt-in; these headers are no-op without crossOriginIsolated
+ // but make local dev parity with a future CORS-isolated host.
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ },
+ },
+});
diff --git a/docs/adr/ADR-092-nvsim-dashboard-implementation.md b/docs/adr/ADR-092-nvsim-dashboard-implementation.md
index 51c36030..0df5a319 100644
--- a/docs/adr/ADR-092-nvsim-dashboard-implementation.md
+++ b/docs/adr/ADR-092-nvsim-dashboard-implementation.md
@@ -810,6 +810,94 @@ WASM/WS swap painless and what enables the third-party `@ruvnet/nvsim-client` pa
---
+## 14a. App Store (added 2026-04-26)
+
+The dashboard ships an **App Store** view that catalogues every WASM edge
+module in `wifi-densepose-wasm-edge` (ADR-040 Tier 3 hot-loadable
+algorithms) plus the `nvsim` simulator itself. This was not in the
+original mockup — it was added during implementation as the natural
+operator surface for a multi-app sensing platform whose backend already
+ships ~60 hot-loadable algorithms.
+
+### 14a.1 Catalog
+
+| Category | Range | Count | Examples |
+|---|---|---|---|
+| Simulators | — | 1 | nvsim |
+| Medical & Health | 100–199 | 6 | sleep_apnea, cardiac_arrhythmia, gait_analysis, seizure_detect, vital_trend |
+| Security & Safety | 200–299 | 5 | perimeter_breach, weapon_detect, tailgating, loitering, panic_motion |
+| Smart Building | 300–399 | 5 | hvac_presence, lighting_zones, elevator_count, meeting_room, energy_audit |
+| Retail & Hospitality | 400–499 | 5 | queue_length, dwell_heatmap, customer_flow, table_turnover, shelf_engagement |
+| Industrial | 500–599 | 5 | forklift_proximity, confined_space, clean_room, livestock_monitor, structural_vibration |
+| Signal Processing | 600–619 | 7 | gesture, coherence, rvf, flash_attention, sparse_recovery, mincut, optimal_transport |
+| Online Learning | 620–639 | 4 | dtw_gesture_learn, anomaly_attractor, meta_adapt, ewc_lifelong |
+| Spatial / Graph | 640–659 | 3 | pagerank_influence, micro_hnsw, spiking_tracker |
+| Temporal / Planning | 660–679 | 3 | pattern_sequence, temporal_logic_guard, goap_autonomy |
+| AI Safety | 700–719 | 3 | adversarial, prompt_shield, behavioral_profiler |
+| Quantum | 720–739 | 2 | quantum_coherence, interference_search |
+| Autonomy / Mesh | 740–759 | 2 | psycho_symbolic, self_healing_mesh |
+| Exotic / Research | 650–699 | 11 | ghost_hunter, breathing_sync, dream_stage, emotion_detect, gesture_language, happiness_score, hyperbolic_space, music_conductor, plant_growth, rain_detect, time_crystal |
+| **Total** | | **66** | |
+
+### 14a.2 Per-app metadata
+
+Each entry in `dashboard/src/store/apps.ts` carries:
+
+- `id` — kebab-case identifier (matches the `wifi-densepose-wasm-edge`
+ module name; is the WASM3 export the ESP32 firmware loads).
+- `name` — human-readable label.
+- `category` — short-code for filter chips and event-ID range.
+- `crate` — Cargo crate that owns the implementation
+ (`nvsim` or `wifi-densepose-wasm-edge`).
+- `summary` — single-line description shown on the card.
+- `events` — emitted i32 event IDs from the `event_types` mod.
+- `budget` — compute tier (`S` < 5 ms, `M` < 15 ms, `L` < 50 ms).
+- `status` — maturity (`available` / `beta` / `research`).
+- `adr` — back-reference to the ADR that introduced or governs the app.
+- `tags` — fuzzy-search tokens.
+
+### 14a.3 UI behavior
+
+- **Card grid** — auto-fill at 280 px per card; theme-aware palette.
+- **Search** — fuzzy match across `id`, `name`, `summary`, and `tags`.
+- **Category chips** — single-select filter (sticky under the search).
+- **Status chips** — secondary filter on maturity.
+- **Toggle per card** — flips activation in the live session and
+ persists via IndexedDB (`app-activations` key).
+- **Active indicator** — emerald border on cards whose toggle is on.
+
+### 14a.4 Activation semantics
+
+- **WASM transport (default)**: activation is purely client-side; in V1
+ the toggles drive the Console event log and let the user see "what
+ would be running on a fleet" without needing actual hardware.
+- **WS transport (deferred to V2)**: activation flips an
+ `app.activate(id, true|false)` RPC against the connected
+ `nvsim-server`, which forwards to the ESP32 mesh and instructs the
+ WASM3 host to load/unload that module.
+
+### 14a.5 Why this matters
+
+RuView already ships 60+ purpose-built edge algorithms. Without an
+operator surface they exist only in source code; the App Store makes
+them **discoverable** and **toggleable** without recompiling firmware.
+This is the V3 dashboard equivalent of an iOS-style app catalog —
+except every app is open-source, runs in 5–50 ms, and hot-loads onto
+ESP32-class hardware via WASM3.
+
+### 14a.6 Adding a new app
+
+1. Implement the algorithm in `wifi-densepose-wasm-edge/src/.rs`.
+2. Add `pub mod ;` to `lib.rs`.
+3. Add an entry to `APPS` in `dashboard/src/store/apps.ts`.
+4. Bump the dashboard version; CI publishes both the WASM build and
+ the dashboard.
+
+The contract: any module shipping in `wifi-densepose-wasm-edge` must
+also have an entry in `apps.ts` (lint check planned for V2).
+
+---
+
## 15. Cross-references
- **ADR-089** — `nvsim` simulator (the backend this dashboard fronts)
diff --git a/v2/crates/nvsim/Cargo.toml b/v2/crates/nvsim/Cargo.toml
index 05620645..23464118 100644
--- a/v2/crates/nvsim/Cargo.toml
+++ b/v2/crates/nvsim/Cargo.toml
@@ -10,6 +10,16 @@ keywords = ["nv-diamond", "magnetometer", "simulator", "physics", "biot-savart"]
categories = ["science", "simulation"]
readme = "README.md"
+[package.metadata.wasm-pack.profile.release]
+# Skip wasm-opt locally — older wasm-opt versions reject bulk-memory ops
+# rustc emits at 1.92. CI runs wasm-opt with a current binaryen.
+wasm-opt = false
+
+[lib]
+# `cdylib` for wasm-bindgen's wasm32 build, `rlib` so other workspace
+# crates and benchmarks can keep linking against nvsim natively.
+crate-type = ["cdylib", "rlib"]
+
# `nvsim` is a standalone leaf crate. It deliberately has NO internal RuView
# dependencies — see `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`
# §1.1 for the rationale. RuView integration (frame format alignment with
@@ -24,15 +34,27 @@ tracing = { workspace = true }
# Pass 4: deterministic ChaCha20 PRNG for shot-noise sampling. Same
# `(scene, seed)` produces byte-identical outputs across runs and machines —
-# the determinism commitment in plan §5.
-rand = "0.8"
-rand_chacha = "0.3"
+# the determinism commitment in plan §5. Default features off to drop the
+# `getrandom` OS-entropy path; nvsim seeds from a caller-supplied u64 so
+# OS entropy is never needed (this is also what makes nvsim WASM-ready).
+rand = { version = "0.8", default-features = false }
+rand_chacha = { version = "0.3", default-features = false }
# Pass 5: SHA-256 over concatenated MagFrame bytes is the simulator's
# content-addressable witness. Same scene + seed → same digest, the
# foundation of Pass 6's proof bundle.
sha2 = { workspace = true }
+# ADR-092: optional wasm-bindgen surface for in-browser dashboard.
+# Enable with `--features wasm` and target wasm32-unknown-unknown.
+wasm-bindgen = { version = "0.2", optional = true }
+serde-wasm-bindgen = { version = "0.6", optional = true }
+js-sys = { version = "0.3", optional = true }
+
+[features]
+default = []
+wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:js-sys"]
+
[dev-dependencies]
approx = "0.5"
criterion = { workspace = true }
diff --git a/v2/crates/nvsim/src/lib.rs b/v2/crates/nvsim/src/lib.rs
index 982fd6cb..b14504c6 100644
--- a/v2/crates/nvsim/src/lib.rs
+++ b/v2/crates/nvsim/src/lib.rs
@@ -48,6 +48,9 @@ pub mod scene;
pub mod sensor;
pub mod source;
+#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+pub mod wasm;
+
pub use proof::Proof;
pub use digitiser::{
diff --git a/v2/crates/nvsim/src/wasm.rs b/v2/crates/nvsim/src/wasm.rs
new file mode 100644
index 00000000..af1d3278
--- /dev/null
+++ b/v2/crates/nvsim/src/wasm.rs
@@ -0,0 +1,160 @@
+//! WASM bindings for `nvsim` — ADR-092 dashboard transport.
+//!
+//! Exposes the deterministic pipeline through a small `wasm-bindgen`
+//! surface so the Vite + Lit dashboard can run the *real* Rust simulator
+//! in a Web Worker. Same `(scene, config, seed)` → byte-identical
+//! `MagFrame` stream and SHA-256 witness as native — that's the
+//! determinism contract the dashboard's Witness panel asserts.
+//!
+//! Only compiled when the `wasm` feature is on; gated to `target = wasm32`
+//! so the rest of the workspace stays unaffected.
+
+#![cfg(all(feature = "wasm", target_arch = "wasm32"))]
+
+use wasm_bindgen::prelude::*;
+
+use crate::pipeline::{Pipeline, PipelineConfig};
+use crate::scene::Scene;
+
+/// Build identifier surfaced to the dashboard so it can pin a specific
+/// nvsim version + the SHA-256 of the `.wasm` artifact (the latter is
+/// computed by the dashboard, not here, but this string is part of what
+/// the dashboard logs at boot).
+pub const NVSIM_BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+/// Convert a `JsValue` error from `serde_wasm_bindgen` into a JS-side
+/// `Error` with a useful message.
+fn js_err(msg: impl AsRef) -> JsValue {
+ JsValue::from_str(msg.as_ref())
+}
+
+/// In-browser pipeline. Wraps [`Pipeline`] with JS-friendly construction
+/// (JSON for `Scene` and `PipelineConfig`) and `Vec` outputs (raw
+/// concatenated [`MagFrame`] bytes — 60 bytes/frame, magic `0xC51A_6E70`).
+#[wasm_bindgen]
+pub struct WasmPipeline {
+ inner: Pipeline,
+}
+
+#[wasm_bindgen]
+impl WasmPipeline {
+ /// Construct from JSON strings + a `seed` (BigInt-friendly; passed in
+ /// as `f64` since wasm-bindgen does not yet ergonomically pass `u64`,
+ /// then bit-cast through `as u64`). The dashboard sends seeds as
+ /// `Number(seed_hex)` from a 32-bit value to fit cleanly.
+ #[wasm_bindgen(constructor)]
+ pub fn new(scene_json: &str, config_json: &str, seed: f64) -> Result {
+ let scene: Scene =
+ serde_json::from_str(scene_json).map_err(|e| js_err(format!("scene parse: {e}")))?;
+ let config: PipelineConfig = serde_json::from_str(config_json)
+ .map_err(|e| js_err(format!("config parse: {e}")))?;
+ let seed_u64 = seed as u64;
+ Ok(WasmPipeline {
+ inner: Pipeline::new(scene, config, seed_u64),
+ })
+ }
+
+ /// Run `n_samples` of the pipeline and return the concatenated raw
+ /// `MagFrame` bytes (`n_samples * sensors * 60` bytes). The dashboard
+ /// parses this into typed records on the main thread.
+ #[wasm_bindgen]
+ pub fn run(&self, n_samples: usize) -> Vec {
+ let frames = self.inner.run(n_samples);
+ let mut out = Vec::with_capacity(frames.len() * 60);
+ for f in &frames {
+ out.extend_from_slice(&f.to_bytes());
+ }
+ out
+ }
+
+ /// Run + SHA-256 witness in one call. Returns a JS object
+ /// `{ frames: Uint8Array, witness: Uint8Array }`. Same
+ /// `(scene, config, seed)` produces byte-identical `witness` across
+ /// runs, machines, and transports — the regression dashboard pins.
+ #[wasm_bindgen(js_name = runWithWitness)]
+ pub fn run_with_witness(&self, n_samples: usize) -> Result {
+ let (frames, witness) = self.inner.run_with_witness(n_samples);
+
+ let mut bytes = Vec::with_capacity(frames.len() * 60);
+ for f in &frames {
+ bytes.extend_from_slice(&f.to_bytes());
+ }
+
+ // Use js_sys::Object directly — keeps the call cheap and avoids
+ // pulling serde_wasm_bindgen on the hot path.
+ let obj = js_sys::Object::new();
+ let frames_arr = js_sys::Uint8Array::new_with_length(bytes.len() as u32);
+ frames_arr.copy_from(&bytes);
+ let witness_arr = js_sys::Uint8Array::new_with_length(32);
+ witness_arr.copy_from(&witness);
+ js_sys::Reflect::set(&obj, &JsValue::from_str("frames"), &frames_arr)?;
+ js_sys::Reflect::set(&obj, &JsValue::from_str("witness"), &witness_arr)?;
+ js_sys::Reflect::set(
+ &obj,
+ &JsValue::from_str("frameCount"),
+ &JsValue::from_f64(frames.len() as f64),
+ )?;
+ Ok(obj.into())
+ }
+
+ /// nvsim build version (semver from Cargo.toml).
+ #[wasm_bindgen(js_name = buildVersion)]
+ pub fn build_version() -> String {
+ NVSIM_BUILD_VERSION.to_string()
+ }
+
+ /// Magic constant for the `MagFrame` v1 binary record. The dashboard's
+ /// hex-dump panel highlights these four bytes (`0xC51A_6E70` → `701A6EC5`
+ /// little-endian) as a sanity check.
+ #[wasm_bindgen(js_name = frameMagic)]
+ pub fn frame_magic() -> u32 {
+ crate::frame::MAG_FRAME_MAGIC
+ }
+
+ /// Bytes-per-frame for v1 — `60` today; surfaced so the dashboard
+ /// can advance its parse cursor without re-deriving the layout.
+ #[wasm_bindgen(js_name = frameBytes)]
+ pub fn frame_bytes() -> u32 {
+ crate::frame::MAG_FRAME_BYTES as u32
+ }
+}
+
+/// Convenience: parse the bundled reference scene to JSON. Lets the
+/// dashboard's "load reference scene" flow round-trip through the Rust
+/// type system instead of duplicating the JSON literal in the JS code.
+#[wasm_bindgen(js_name = referenceSceneJson)]
+pub fn reference_scene_json() -> String {
+ crate::proof::Proof::REFERENCE_SCENE_JSON.to_string()
+}
+
+/// Hex-encode a 32-byte witness for display.
+#[wasm_bindgen(js_name = hexWitness)]
+pub fn hex_witness(witness: &[u8]) -> Result {
+ if witness.len() != 32 {
+ return Err(js_err(format!(
+ "witness must be 32 bytes, got {}",
+ witness.len()
+ )));
+ }
+ let mut a = [0u8; 32];
+ a.copy_from_slice(witness);
+ Ok(crate::proof::Proof::hex(&a))
+}
+
+/// Expected reference witness for `Proof::REFERENCE_SCENE_JSON @ seed=42,
+/// N=256` — the bytes the dashboard's Verify panel compares against.
+#[wasm_bindgen(js_name = expectedReferenceWitnessHex)]
+pub fn expected_reference_witness_hex() -> String {
+ "cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4".to_string()
+}
+
+/// Run the canonical reference pipeline (`Proof::generate`) end-to-end and
+/// return the SHA-256 witness as a 32-byte `Uint8Array`. This is the
+/// dashboard's source of truth for the Verify-witness panel.
+#[wasm_bindgen(js_name = referenceWitness)]
+pub fn reference_witness() -> Result {
+ let bytes = crate::proof::Proof::generate().map_err(|e| js_err(format!("{e}")))?;
+ let arr = js_sys::Uint8Array::new_with_length(32);
+ arr.copy_from(&bytes);
+ Ok(arr)
+}