feat(dashboard): nvsim Vite+Lit dashboard with WASM transport + App Store [ADR-092]

End-to-end implementation of the operator dashboard for the nvsim
NV-diamond magnetometer simulator. Vite 5 + TypeScript strict + Lit 3,
~93 KB gzipped JS budget, runs the *real* nvsim Rust crate compiled to
wasm32-unknown-unknown inside a dedicated Web Worker.

Validated end-to-end with `npx agent-browser`:
- WASM module boots, build version + magic 0xC51A_6E70 reported
- Reference witness verifies byte-identical to Proof::EXPECTED_WITNESS_HEX
  cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4
- Pipeline runs at ~1.88 kHz on x86_64 dev hardware (4500x over Cortex-A53)
- Zero browser console errors; only Lit dev-mode warning (expected)

## nvsim crate (additive)
- New `wasm` feature flag with wasm-bindgen 0.2 / serde-wasm-bindgen 0.6
- src/wasm.rs: WasmPipeline wrapper + referenceSceneJson +
  expectedReferenceWitnessHex + referenceWitness + hexWitness exports
- crate-type = ["cdylib", "rlib"] so native + wasm both build
- rand = { default-features = false } drops getrandom OS-entropy path,
  preserving the crate's WASM-ready posture
- Native: 50/50 tests still pass, witness unchanged

## dashboard/ (new package)
- Vite 5 + TypeScript strict, Lit 3 elements, signals-based store
- 12 Lit components mirroring the mockup zones (rail, topbar, sidebar,
  scene SVG with draggable sources + NV crystal, inspector tabs
  Signal/Frame/Witness, console with REPL + filter tabs, settings
  drawer, modals, ⌘K command palette, debug HUD, toast, app-store)
- IndexedDB persistence (theme, density, motion, app activations)
- WasmClient → Web Worker → wasm-pack-built nvsim WASM module
- NvsimClient TS interface — same shape covers future WsClient transport
- MagFrame parser (60-byte LE layout matching nvsim::frame)

## App Store (ADR-092 §14a — added during impl)
- Catalog of all 65 wifi-densepose-wasm-edge modules + nvsim
- 13 categories with event-ID-range labels
- Per-app metadata: id/name/category/crate/summary/events/budget/
  status/adr/tags
- Fuzzy search, category + status filters, IndexedDB-backed activation
- ADR-092 §14a documents the registry contract and per-app schema

## Build pipeline
- wasm-pack build crates/nvsim --target web outputs to
  dashboard/public/nvsim-pkg/ (60 KB pkg, 162 KB unoptimized .wasm)
- npm run build → 93 KB gzip JS, well under 300 KB budget
- ts.config strict, npx tsc --noEmit clean
- Vite worker correctly loads WASM via dynamic import resolving
  against worker origin

## E2E validation
- agent-browser open → 4-zone grid renders correctly in dark theme
- Run button → live B-vector trace, |B| readout updates, FPS counter
- App Store → all 66 apps listed with toggles, fuzzy search filters
  to "Ghost hunter" on "ghost" query
- Witness verify → green check, console logs "determinism gate ✓"
- Console errors: zero (only expected Lit dev-mode warning)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-04-26 19:22:04 -04:00
parent db1ccbff49
commit 39ec05edcb
31 changed files with 10107 additions and 3 deletions

5
dashboard/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.vite
*.log
public/nvsim-pkg

18
dashboard/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>RuView · nvsim — NV-Diamond Magnetometer Simulator</title>
<meta name="description" content="Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs." />
<meta name="theme-color" content="#0d1117" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23e6a86b'/><text x='16' y='22' text-anchor='middle' font-family='monospace' font-weight='700' font-size='14' fill='%231a0f00'>NV</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<nv-app></nv-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6438
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
dashboard/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@ruvnet/nvsim-dashboard",
"version": "0.1.0",
"description": "Vite + Lit dashboard for the nvsim NV-diamond magnetometer pipeline simulator (ADR-092).",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 4173",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@preact/signals-core": "^1.8.0",
"lit": "^3.2.1"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^2.1.4"
}
}

92
dashboard/src/app.css Normal file
View File

@ -0,0 +1,92 @@
/* nvsim dashboard global styles
Ported from `assets/NVsim Dashboard.zip` per ADR-092 §7.1.
Per-component scoped styles live in each Lit element. */
:root {
--bg-0: #07090d;
--bg-1: #0d1117;
--bg-2: #131a23;
--bg-3: #1a232f;
--line: #1f2a38;
--line-2: #2a3848;
--ink: #e6edf3;
--ink-2: #b8c2cc;
--ink-3: #7c8694;
--ink-4: #4a5462;
--accent: oklch(0.78 0.14 70);
--accent-2: oklch(0.78 0.12 195);
--accent-3: oklch(0.72 0.18 330);
--accent-4: oklch(0.78 0.14 145);
--warn: oklch(0.7 0.18 35);
--ok: oklch(0.78 0.14 145);
--bad: oklch(0.65 0.22 25);
--grid: rgba(255, 255, 255, 0.04);
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6),
0 4px 12px -4px rgba(0, 0, 0, 0.4);
--radius: 12px;
--radius-sm: 8px;
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: 'Inter', system-ui, -apple-system, sans-serif;
}
[data-theme="light"] {
--bg-0: #f4f5f7;
--bg-1: #fbfbfc;
--bg-2: #ffffff;
--bg-3: #f0f2f5;
--line: #e3e7ec;
--line-2: #d1d7de;
--ink: #11161d;
--ink-2: #38424f;
--ink-3: #6b7684;
--ink-4: #9ba4b0;
--grid: rgba(0, 0, 0, 0.05);
--shadow: 0 12px 40px -16px rgba(15, 30, 55, 0.18),
0 2px 8px -2px rgba(15, 30, 55, 0.08);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--sans);
background: var(--bg-0);
color: var(--ink);
font-size: 14px;
line-height: 1.45;
overflow: hidden;
height: 100vh;
-webkit-font-smoothing: antialiased;
letter-spacing: -0.005em;
}
button { font-family: inherit; color: inherit; cursor: pointer; }
input, select { font-family: inherit; color: inherit; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--ink-4); }
@keyframes pulse { 50% { opacity: 0.5; } }
@keyframes dash { to { stroke-dashoffset: -200; } }
@keyframes float-up {
0% { opacity: 0; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes diamond-spin {
0% { transform: rotateY(0) rotateX(8deg); }
100% { transform: rotateY(360deg) rotateX(8deg); }
}
@keyframes spin { to { transform: rotate(360deg); } }
body.reduce-motion *,
body.reduce-motion *::before,
body.reduce-motion *::after {
animation: none !important;
transition: none !important;
}
/* Density (set via class on <body> by setDensity()) */
body.density-comfy { font-size: 15px; }
body.density-default { font-size: 14px; }
body.density-compact { font-size: 13px; }

View File

@ -0,0 +1,283 @@
/* App Store catalog of every WASM edge module + simulator app.
*
* Mirrors `wifi-densepose-wasm-edge`'s 60+ hot-loadable algorithms and
* the `nvsim` simulator. Each card is filterable by category, fuzzy
* name search, and maturity (available / beta / research). A toggle on
* each card flips activation in the live session that drives the
* dashboard's event log when running. WS transport (future) pushes the
* activation set to the connected ESP32 mesh.
*
* ADR-092 §18.
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { signal, effect } from '@preact/signals-core';
import {
APPS, CATEGORIES, defaultActivations, fuzzyMatch,
type AppCategory, type AppManifest, type AppActivation,
} from '../store/apps';
import { kvGet, kvSet } from '../store/persistence';
import { pushLog } from '../store/appStore';
const activations = signal<AppActivation[]>(defaultActivations());
const query = signal<string>('');
const activeCat = signal<AppCategory | 'all'>('all');
const statusFilter = signal<'all' | 'available' | 'beta' | 'research'>('all');
(async () => {
const saved = await kvGet<AppActivation[]>('app-activations');
if (saved) activations.value = saved;
})();
effect(() => {
// Persist activations on change (post-load).
const v = activations.value;
if (v.length > 0) void kvSet('app-activations', v);
});
@customElement('nv-app-store')
export class NvAppStore extends LitElement {
@state() private renderTick = 0;
static styles = css`
:host {
display: block;
height: 100%;
overflow-y: auto;
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
padding: 24px;
}
.head {
display: flex; align-items: center; gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.ttl {
font-size: 22px; font-weight: 700; letter-spacing: -0.02em;
color: var(--ink);
flex: 1; min-width: 200px;
}
.ttl small {
font-size: 12.5px; font-weight: 400;
color: var(--ink-3); margin-left: 8px;
}
.search {
width: 320px; max-width: 100%;
padding: 8px 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 8px;
font-family: var(--mono);
font-size: 12.5px;
color: var(--ink); outline: none;
}
.search:focus { border-color: var(--accent); }
.filters {
display: flex; flex-wrap: wrap; gap: 6px;
margin-bottom: 18px;
}
.chip {
padding: 4px 10px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11.5px; color: var(--ink-3);
cursor: pointer;
font-family: var(--mono);
display: inline-flex; align-items: center; gap: 4px;
}
.chip:hover { color: var(--ink); border-color: var(--line-2); }
.chip.on { background: var(--bg-3); border-color: var(--accent); color: var(--ink); }
.chip .swatch {
width: 7px; height: 7px; border-radius: 50%;
}
.chip .count { color: var(--ink-3); font-size: 10px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.card {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px 14px;
display: flex; flex-direction: column; gap: 6px;
transition: border-color 0.15s, transform 0.15s;
position: relative;
}
.card:hover { border-color: var(--line-2); transform: translateY(-1px); }
.card.active {
border-color: oklch(0.78 0.14 145 / 0.7);
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 145 / 0.04) 100%);
}
.card-h {
display: flex; align-items: flex-start; gap: 8px;
margin-bottom: 2px;
}
.card-h .name {
font-size: 13.5px; font-weight: 600; color: var(--ink);
flex: 1; line-height: 1.3;
}
.card-h .swatch {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0; margin-top: 4px;
}
.summary {
font-size: 12px; color: var(--ink-2); line-height: 1.45;
flex: 1;
}
.meta {
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;
font-family: var(--mono); font-size: 10px;
}
.badge {
padding: 1px 6px; border-radius: 4px;
background: var(--bg-3); color: var(--ink-3);
border: 1px solid var(--line);
}
.badge.cat { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.3); }
.badge.status-available { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
.badge.status-beta { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
.badge.status-research { color: var(--accent-3); border-color: oklch(0.72 0.18 330 / 0.4); }
.badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); }
.card-foot {
display: flex; align-items: center; gap: 8px;
padding-top: 8px; margin-top: 4px;
border-top: 1px solid var(--line);
font-size: 11px; color: var(--ink-3);
}
.toggle {
position: relative;
width: 32px; height: 18px;
background: var(--bg-3); border: 1px solid var(--line-2);
border-radius: 999px; cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.toggle::after {
content: ''; position: absolute;
top: 1px; left: 1px;
width: 12px; height: 12px;
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(14px); }
.events {
font-family: var(--mono); font-size: 10px; color: var(--ink-3);
flex: 1;
}
.empty {
padding: 40px;
text-align: center; color: var(--ink-3);
font-size: 13px;
}
`;
override connectedCallback(): void {
super.connectedCallback();
effect(() => { activations.value; query.value; activeCat.value; statusFilter.value; this.renderTick++; });
}
private isActive(id: string): boolean {
return activations.value.find((a) => a.id === id)?.active === true;
}
private toggle(app: AppManifest): void {
const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a);
activations.value = next;
pushLog(this.isActive(app.id) ? 'ok' : 'info', `app <span class="k">${app.id}</span> deactivated`);
}
private filtered(): AppManifest[] {
let list = APPS;
if (activeCat.value !== 'all') list = list.filter((a) => a.category === activeCat.value);
if (statusFilter.value !== 'all') list = list.filter((a) => a.status === statusFilter.value);
if (query.value.trim()) {
list = list
.map((a) => ({ a, s: fuzzyMatch(query.value, a) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.map((x) => x.a);
}
return list;
}
private categoryCounts(): Record<string, number> {
const counts: Record<string, number> = { all: APPS.length };
for (const k of Object.keys(CATEGORIES)) counts[k] = 0;
for (const a of APPS) counts[a.category] = (counts[a.category] ?? 0) + 1;
return counts;
}
override render() {
const list = this.filtered();
const counts = this.categoryCounts();
const activeCount = activations.value.filter((a) => a.active).length;
return html`
<div class="head">
<div class="ttl">
App Store
<small>${APPS.length} edge apps · ${activeCount} active</small>
</div>
<input class="search" id="app-search" placeholder="Search by name, tag, or category…"
.value=${query.value}
@input=${(e: Event) => { query.value = (e.target as HTMLInputElement).value; }} />
</div>
<div class="filters">
<span class="chip ${activeCat.value === 'all' ? 'on' : ''}"
@click=${() => activeCat.value = 'all'}>
All<span class="count">${counts.all}</span>
</span>
${(Object.keys(CATEGORIES) as AppCategory[]).map((k) => html`
<span class="chip ${activeCat.value === k ? 'on' : ''}"
@click=${() => activeCat.value = k}>
<span class="swatch" style=${`background:${CATEGORIES[k].color}`}></span>
${CATEGORIES[k].label}
<span class="count">${counts[k] ?? 0}</span>
</span>
`)}
<span style="flex:1; min-width:8px"></span>
<span class="chip ${statusFilter.value === 'all' ? 'on' : ''}" @click=${() => statusFilter.value = 'all'}>any</span>
<span class="chip ${statusFilter.value === 'available' ? 'on' : ''}" @click=${() => statusFilter.value = 'available'}>available</span>
<span class="chip ${statusFilter.value === 'beta' ? 'on' : ''}" @click=${() => statusFilter.value = 'beta'}>beta</span>
<span class="chip ${statusFilter.value === 'research' ? 'on' : ''}" @click=${() => statusFilter.value = 'research'}>research</span>
</div>
${list.length === 0
? html`<div class="empty">No apps match the current filters.</div>`
: html`<div class="grid">${list.map((app) => this.card(app))}</div>`}
`;
}
private card(app: AppManifest) {
const active = this.isActive(app.id);
const cat = CATEGORIES[app.category];
return html`
<div class="card ${active ? 'active' : ''}" data-app-id=${app.id}>
<div class="card-h">
<span class="swatch" style=${`background:${cat.color}`}></span>
<span class="name">${app.name}</span>
</div>
<div class="summary">${app.summary}</div>
<div class="meta">
<span class="badge cat">${cat.label}</span>
<span class="badge status-${app.status}">${app.status}</span>
${app.budget ? html`<span class="badge budget">budget ${app.budget}</span>` : ''}
${app.adr ? html`<span class="badge">${app.adr}</span>` : ''}
${app.events?.length ? html`<span class="badge">events ${app.events.join('·')}</span>` : ''}
</div>
<div class="card-foot">
<span class="events">${app.crate}</span>
<span class="toggle ${active ? 'on' : ''}" role="switch"
aria-checked=${active}
data-app-toggle=${app.id}
@click=${() => this.toggle(app)}></span>
</div>
</div>
`;
}
}

View File

@ -0,0 +1,92 @@
/* Top-level shell: 4-zone grid with rail / topbar / sidebar / scene / inspector / console.
* View routing is per-rail-button: the central area swaps between
* `<nv-scene>`, `<nv-app-store>`, etc. */
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import './nv-rail';
import './nv-topbar';
import './nv-sidebar';
import './nv-scene';
import './nv-inspector';
import './nv-console';
import './nv-app-store';
import './nv-toast';
import './nv-modal';
import './nv-palette';
import './nv-debug-hud';
import './nv-settings-drawer';
export type View = 'scene' | 'apps' | 'settings';
@customElement('nv-app')
export class NvApp extends LitElement {
@state() private view: View = 'scene';
static styles = css`
:host {
display: block;
height: 100vh;
width: 100vw;
background: var(--bg-0);
}
.app {
display: grid;
grid-template-columns: 56px 280px 1fr 340px;
grid-template-rows: 48px 1fr 220px;
grid-template-areas:
'rail topbar topbar topbar'
'rail sidebar main inspector'
'rail sidebar console inspector';
height: 100vh;
width: 100vw;
}
nv-rail { grid-area: rail; }
nv-topbar { grid-area: topbar; }
nv-sidebar { grid-area: sidebar; }
.main { grid-area: main; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
nv-inspector { grid-area: inspector; }
nv-console { grid-area: console; min-height: 0; }
@media (max-width: 1180px) {
.app {
grid-template-columns: 56px 1fr 320px;
grid-template-areas:
'rail topbar topbar'
'rail main inspector'
'rail console console';
}
nv-sidebar { display: none; }
}
@media (max-width: 860px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: 52px 1fr 200px;
grid-template-areas:
'topbar'
'main'
'console';
}
nv-rail, nv-sidebar, nv-inspector { display: none; }
}
`;
override render() {
return html`
<div class="app">
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
<nv-topbar></nv-topbar>
<nv-sidebar></nv-sidebar>
<div class="main">
${this.view === 'apps' ? html`<nv-app-store></nv-app-store>` : html`<nv-scene></nv-scene>`}
</div>
<nv-inspector></nv-inspector>
<nv-console></nv-console>
</div>
<nv-toast></nv-toast>
<nv-modal></nv-modal>
<nv-palette></nv-palette>
<nv-debug-hud></nv-debug-hud>
<nv-settings-drawer></nv-settings-drawer>
`;
}
}

View File

@ -0,0 +1,249 @@
/* Console — log stream + REPL. */
import { LitElement, html, css } from 'lit';
import { customElement, query } from 'lit/decorators.js';
import { effect } from '@preact/signals-core';
import {
consoleLines, consoleFilter, consolePaused, pushLog,
getClient, seed, theme, expectedWitness, witnessHex, witnessVerified,
running,
} from '../store/appStore';
@customElement('nv-console')
export class NvConsole extends LitElement {
@query('#console-input') private inputEl!: HTMLInputElement;
private history: string[] = [];
private hIdx = -1;
static styles = css`
:host {
display: flex; flex-direction: column;
background: var(--bg-1);
overflow: hidden;
}
.tabs {
display: flex; align-items: center;
border-bottom: 1px solid var(--line);
padding: 0 10px;
gap: 2px;
}
.tab {
padding: 8px 12px;
background: transparent; border: none;
font-size: 11.5px; color: var(--ink-3);
font-family: var(--mono);
border-bottom: 2px solid transparent;
cursor: pointer;
margin-bottom: -1px;
}
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
.tab .cnt {
background: var(--bg-3); padding: 1px 5px; border-radius: 999px;
font-size: 9.5px; color: var(--ink-2); margin-left: 4px;
}
.spacer { flex: 1; }
.tools { display: flex; gap: 4px; padding: 4px 0; }
.tools button {
width: 24px; height: 24px;
background: transparent; border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink-3);
font-size: 11px; cursor: pointer;
}
.tools button:hover { color: var(--ink); border-color: var(--line-2); }
.body {
flex: 1; overflow-y: auto;
font-family: var(--mono);
font-size: 11.5px;
padding: 6px 0;
background: var(--bg-0);
}
.line {
display: grid;
grid-template-columns: 70px 60px 1fr;
gap: 12px;
padding: 2px 12px;
color: var(--ink-2);
border-left: 2px solid transparent;
}
.line:hover { background: var(--bg-1); }
.ts { color: var(--ink-4); font-size: 10.5px; padding-top: 1px; }
.lvl {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px;
}
.line.info .lvl { color: var(--accent-2); }
.line.warn .lvl { color: var(--warn); }
.line.warn { border-left-color: var(--warn); background: oklch(0.7 0.18 35 / 0.04); }
.line.err .lvl { color: var(--bad); }
.line.err { border-left-color: var(--bad); background: oklch(0.65 0.22 25 / 0.05); }
.line.dbg .lvl { color: var(--ink-3); }
.line.ok .lvl { color: var(--ok); }
.msg { color: var(--ink); white-space: pre-wrap; word-break: break-word; }
.input {
display: flex; align-items: center;
border-top: 1px solid var(--line);
background: var(--bg-0);
padding: 0 10px;
height: 32px; gap: 8px;
}
.prompt { color: var(--accent); font-family: var(--mono); font-size: 12px; }
input[type="text"] {
flex: 1; background: transparent; border: none; outline: none;
color: var(--ink); font-family: var(--mono); font-size: 12px;
height: 100%;
}
input::placeholder { color: var(--ink-4); }
`;
override connectedCallback(): void {
super.connectedCallback();
effect(() => {
consoleLines.value; consoleFilter.value; consolePaused.value;
this.requestUpdate();
});
}
override updated(): void {
const body = this.renderRoot.querySelector('.body') as HTMLElement | null;
if (body) body.scrollTop = body.scrollHeight;
}
private counts(): Record<string, number> {
const c: Record<string, number> = { info: 0, warn: 0, err: 0, dbg: 0, ok: 0 };
for (const l of consoleLines.value) c[l.level] = (c[l.level] ?? 0) + 1;
c.all = consoleLines.value.length;
return c;
}
private async exec(line: string): Promise<void> {
line = line.trim();
if (!line) return;
pushLog('info', `<span style="color:var(--accent);">nvsim&gt;</span> ${line}`);
this.history.push(line); this.hIdx = this.history.length;
const [cmd, ...args] = line.split(/\s+/);
const arg = args.join(' ');
const c = getClient();
switch (cmd) {
case 'help':
pushLog('info', 'commands: help · scene.list · sensor.config · run · pause · seed · proof.verify · clear · theme · status');
break;
case 'scene.list':
pushLog('info', 'scene rebar-walkby-01:');
pushLog('info', ' rebar.steel.coil @ [+2.7, 0.0, +0.3] m χ=5000');
pushLog('info', ' dipole.heart_proxy @ [-1.4, +0.2, +0.4] m m=1.0e-6 A·m²');
pushLog('info', ' loop.mains_60Hz @ [-1.6, -0.4, 0.0] m I=2 A');
pushLog('info', ' eddy.door_steel @ [+0.0, +1.8, +0.4] m σ=1e6 S/m');
break;
case 'sensor.config':
pushLog('info', 'NvSensor::cots_defaults() {');
pushLog('info', ' pos=[0,0,0], V=1mm³, N=1e12, C=0.03, T2*=200ns');
pushLog('info', ' D=2.870 GHz, γe=28 GHz/T, Γ=1.0 MHz, axes=4×〈111〉');
pushLog('info', ' δB ≈ 1.18 pT/√Hz (Barry 2020 §III.A) }');
break;
case 'run':
if (c) { await c.run(); running.value = true; pushLog('ok', 'pipeline RUN'); }
break;
case 'pause':
if (c) { await c.pause(); running.value = false; pushLog('warn', 'pipeline PAUSED'); }
break;
case 'reset':
if (c) { await c.reset(); pushLog('info', 'pipeline reset · t=0'); }
break;
case 'seed': {
if (!arg) { pushLog('info', `current seed = 0x${seed.value.toString(16).toUpperCase()}`); break; }
const v = BigInt(arg.startsWith('0x') ? arg : '0x' + arg);
seed.value = v;
if (c) await c.setSeed(v);
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
break;
}
case 'proof.verify': {
if (!c) break;
pushLog('dbg', 'computing SHA-256 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'; pushLog('err', 'WITNESS MISMATCH'); }
} catch (e) { pushLog('err', `verify failed: ${(e as Error).message}`); }
break;
}
case 'clear':
consoleLines.value = [];
break;
case 'theme': {
const t = (arg || '').toLowerCase();
if (t === 'light' || t === 'dark') { theme.value = t; pushLog('ok', `theme → ${t}`); }
else pushLog('info', 'theme [light|dark]');
break;
}
case 'status':
pushLog('info', `running=${running.value} seed=0x${seed.value.toString(16).toUpperCase()} verified=${witnessVerified.value}`);
break;
default:
pushLog('err', `unknown command: ${cmd} · try help`);
}
}
private onKey = (e: KeyboardEvent): void => {
if (e.key === 'Enter') { void this.exec(this.inputEl.value); this.inputEl.value = ''; }
else if (e.key === 'ArrowUp') {
if (this.history.length) {
this.hIdx = Math.max(0, this.hIdx - 1);
this.inputEl.value = this.history[this.hIdx] ?? '';
e.preventDefault();
}
} else if (e.key === 'ArrowDown') {
if (this.history.length) {
this.hIdx = Math.min(this.history.length, this.hIdx + 1);
this.inputEl.value = this.history[this.hIdx] ?? '';
e.preventDefault();
}
}
};
override render() {
const c = this.counts();
const filter = consoleFilter.value;
const visible = consoleLines.value.filter((l) => filter === 'all' || l.level === filter);
return html`
<div class="tabs">
${(['all', 'info', 'warn', 'err', 'dbg'] as const).map((k) => html`
<button class="tab ${filter === k ? 'active' : ''}" data-tab=${k}
@click=${() => consoleFilter.value = k}>
${k} <span class="cnt">${c[k] ?? 0}</span>
</button>
`)}
<span class="spacer"></span>
<div class="tools">
<button id="clear-log" title="Clear" @click=${() => consoleLines.value = []}>×</button>
<button id="pause-log" title="Pause" @click=${() => consolePaused.value = !consolePaused.value}>
${consolePaused.value ? '▶' : '❚❚'}
</button>
</div>
</div>
<div class="body">
${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`<div class="line ${l.level}">
<div class="ts">${tsStr}</div>
<div class="lvl">${l.level}</div>
<div class="msg" .innerHTML=${l.msg}></div>
</div>`;
})}
</div>
<div class="input">
<span class="prompt">nvsim&gt;</span>
<input id="console-input" type="text"
placeholder="help · scene.list · sensor.config · run · proof.verify · clear"
@keydown=${this.onKey}/>
</div>
`;
}
}

View File

@ -0,0 +1,88 @@
/* Debug HUD toggled with `. Shows render fps, sim t, frames, |B|, SNR. */
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { effect } from '@preact/signals-core';
import { fps, framesEmitted, bMag, snr, t as simT } from '../store/appStore';
@customElement('nv-debug-hud')
export class NvDebugHud extends LitElement {
@state() private open = false;
@state() private renderFps = 0;
private lastTs = performance.now();
private frameCount = 0;
private rafId = 0;
static styles = css`
:host {
position: fixed; bottom: 8px; right: 8px;
width: 220px;
background: rgba(13,17,23,0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--line-2);
border-radius: 8px;
padding: 8px 10px;
font-family: var(--mono); font-size: 11px;
color: var(--ink-2);
z-index: 99;
display: none;
box-shadow: var(--shadow);
}
:host([open]) { display: block; }
.h {
display: flex; justify-content: space-between;
font-weight: 600; color: var(--ink);
margin-bottom: 6px; padding-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.x { cursor: pointer; color: var(--ink-3); }
.row {
display: flex; justify-content: space-between;
padding: 1px 0;
}
.k { color: var(--ink-3); }
.v { color: var(--ink); }
`;
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener('keydown', this.onKey);
effect(() => { fps.value; framesEmitted.value; bMag.value; snr.value; simT.value; this.requestUpdate(); });
this.tick();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('keydown', this.onKey);
cancelAnimationFrame(this.rafId);
}
private onKey = (e: KeyboardEvent): void => {
if (e.key === '`' && !(e.target as HTMLElement).matches('input, textarea')) {
this.open = !this.open;
this.toggleAttribute('open', this.open);
}
};
private tick = (): void => {
this.rafId = requestAnimationFrame(this.tick);
const now = performance.now();
this.frameCount++;
if (now - this.lastTs >= 500) {
this.renderFps = (this.frameCount * 1000) / (now - this.lastTs);
this.frameCount = 0;
this.lastTs = now;
this.requestUpdate();
}
};
override render() {
return html`
<div class="h"><span>nvsim · debug</span><span class="x" @click=${() => { this.open = false; this.removeAttribute('open'); }}></span></div>
<div class="row"><span class="k">render fps</span><span class="v">${this.renderFps.toFixed(1)}</span></div>
<div class="row"><span class="k">sim fps</span><span class="v">${fps.value > 0 ? Math.round(fps.value) : '—'}</span></div>
<div class="row"><span class="k">frames</span><span class="v">${framesEmitted.value.toString()}</span></div>
<div class="row"><span class="k">|B|</span><span class="v">${(bMag.value * 1e9).toFixed(3)} nT</span></div>
<div class="row"><span class="k">SNR</span><span class="v">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</span></div>
<div class="row"><span class="k">DOM</span><span class="v">${document.querySelectorAll('*').length}</span></div>
`;
}
}

View File

@ -0,0 +1,268 @@
/* Inspector — tabbed: Signal / Frame / Witness. */
import { LitElement, html, css, svg } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { effect } from '@preact/signals-core';
import {
traceX, traceY, traceZ, stripBars, lastFrame,
witnessHex, expectedWitness, witnessVerified, getClient,
pushLog, lastB, bMag,
} from '../store/appStore';
type Tab = 'signal' | 'frame' | 'witness';
@customElement('nv-inspector')
export class NvInspector extends LitElement {
@state() private tab: Tab = 'signal';
static styles = css`
:host {
display: flex; flex-direction: column;
background: var(--bg-1);
border-left: 1px solid var(--line);
overflow: hidden;
height: 100%;
}
.tabs {
display: flex; border-bottom: 1px solid var(--line);
}
.tab {
flex: 1;
padding: 11px 8px;
background: transparent; border: none;
font-size: 11.5px; font-weight: 500;
color: var(--ink-3);
border-bottom: 2px solid transparent;
cursor: pointer; transition: color 0.15s, border-color 0.15s;
}
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
.tab:hover { color: var(--ink-2); }
.body { padding: 14px; flex: 1; overflow-y: auto; }
.card {
background: var(--bg-2); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px;
margin-bottom: 12px;
}
.card-h {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px;
}
.card-h .ttl { font-size: 12px; font-weight: 600; }
.badge {
font-family: var(--mono); font-size: 10px;
padding: 2px 6px;
background: oklch(0.78 0.14 195 / 0.12);
color: var(--accent-2);
border-radius: 4px;
border: 1px solid oklch(0.78 0.14 195 / 0.3);
}
svg { width: 100%; height: 130px; }
.frame-strip {
height: 28px;
display: flex; align-items: flex-end; gap: 1px;
padding: 4px 0;
}
.bar {
flex: 1;
background: linear-gradient(to top, var(--accent-2), var(--accent));
border-radius: 1px;
min-height: 2px;
}
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; }
td { padding: 4px 0; border-bottom: 1px solid var(--line); }
td:first-child { color: var(--ink-3); }
td:last-child { text-align: right; color: var(--ink); }
.hex {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 10px;
font-family: var(--mono);
font-size: 10.5px;
color: var(--ink-2);
line-height: 1.6;
overflow-x: auto;
white-space: nowrap;
}
.hex .magic { color: var(--accent); font-weight: 600; }
.witness-box {
font-family: var(--mono);
font-size: 11px;
color: var(--ink-2);
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 10px;
word-break: break-all;
line-height: 1.5;
}
.verify-btn {
margin-top: 10px;
width: 100%;
padding: 8px;
border: 1px solid var(--line);
background: var(--bg-3);
color: var(--ink);
border-radius: 8px;
cursor: pointer;
font-family: var(--mono);
font-size: 12px;
}
.verify-btn:hover { border-color: var(--accent); }
.verify-btn.ok { border-color: var(--ok); color: var(--ok); }
.verify-btn.fail { border-color: var(--bad); color: var(--bad); }
`;
override connectedCallback(): void {
super.connectedCallback();
effect(() => {
traceX.value; traceY.value; traceZ.value; stripBars.value;
lastFrame.value; witnessHex.value; witnessVerified.value;
lastB.value; bMag.value;
this.requestUpdate();
});
}
private async verify(): Promise<void> {
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`
<div class="card">
<div class="card-h">
<span class="ttl">B-vector trace</span>
<span class="badge">3-axis · nT</span>
</div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<line x1="0" y1=${cy} x2=${W} y2=${cy} stroke="var(--line)" stroke-width="0.5"/>
${svg`<path id="trace-x" d=${make(traceX.value)} stroke="oklch(0.78 0.14 70)" stroke-width="1.2" fill="none"/>`}
${svg`<path id="trace-y" d=${make(traceY.value)} stroke="oklch(0.78 0.12 195)" stroke-width="1.2" fill="none" opacity="0.8"/>`}
${svg`<path id="trace-z" d=${make(traceZ.value)} stroke="oklch(0.72 0.18 330)" stroke-width="1.2" fill="none" opacity="0.7"/>`}
</svg>
</div>
<div class="card">
<div class="card-h">
<span class="ttl">Frame stream</span>
<span class="badge" id="strip-rate">live</span>
</div>
<div class="frame-strip" id="frame-strip">
${stripBars.value.map((v) => html`<div class="bar" style=${`height:${Math.max(4, v * 100)}%`}></div>`)}
</div>
</div>
`;
}
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`
<div class="card">
<div class="card-h">
<span class="ttl">MagFrame v1 fields</span>
<span class="badge">60 B</span>
</div>
<table>
<tr><td>magic</td><td id="frame-magic">${f ? '0x' + f.magic.toString(16).toUpperCase() : '—'}</td></tr>
<tr><td>version</td><td>${f?.version ?? '—'}</td></tr>
<tr><td>flags</td><td>0x${(f?.flags ?? 0).toString(16).padStart(4, '0')}</td></tr>
<tr><td>sensor_id</td><td>${f?.sensorId ?? '—'}</td></tr>
<tr><td>t_us</td><td>${f ? f.tUs.toString() : '—'}</td></tr>
<tr><td>b_pT[0]</td><td id="frame-bx">${f ? f.bPt[0].toFixed(1) : '—'}</td></tr>
<tr><td>b_pT[1]</td><td id="frame-by">${f ? f.bPt[1].toFixed(1) : '—'}</td></tr>
<tr><td>b_pT[2]</td><td id="frame-bz">${f ? f.bPt[2].toFixed(1) : '—'}</td></tr>
<tr><td>noise_floor</td><td>${f ? f.noiseFloorPtSqrtHz.toFixed(2) : '—'}</td></tr>
<tr><td>temp_K</td><td>${f ? f.temperatureK.toFixed(1) : '—'}</td></tr>
</table>
</div>
<div class="card">
<div class="card-h">
<span class="ttl">Hex dump</span>
<span class="badge">LE</span>
</div>
<div class="hex" id="frame-hex">${hex || '—'}</div>
</div>
`;
}
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`
<div class="card">
<div class="card-h">
<span class="ttl">Expected (Proof::EXPECTED_WITNESS_HEX)</span>
<span class="badge">SHA-256</span>
</div>
<div class="witness-box" id="expected-witness">${expectedWitness.value || '(loading…)'}</div>
</div>
<div class="card">
<div class="card-h">
<span class="ttl">Actual (last verify)</span>
<span class="badge">SHA-256</span>
</div>
<div class="witness-box" id="actual-witness">${witnessHex.value || '(not verified yet)'}</div>
<button class="verify-btn ${cls}" id="verify-btn" @click=${this.verify}>${label}</button>
</div>
`;
}
override render() {
return html`
<div class="tabs">
<button class="tab ${this.tab === 'signal' ? 'active' : ''}" data-pane="signal" @click=${() => this.tab = 'signal'}>Signal</button>
<button class="tab ${this.tab === 'frame' ? 'active' : ''}" data-pane="frame" @click=${() => this.tab = 'frame'}>Frame</button>
<button class="tab ${this.tab === 'witness' ? 'active' : ''}" data-pane="witness" @click=${() => this.tab = 'witness'}>Witness</button>
</div>
<div class="body">
${this.tab === 'signal' ? this.renderSignalTab()
: this.tab === 'frame' ? this.renderFrameTab()
: this.renderWitnessTab()}
</div>
`;
}
}

View File

@ -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`
<div class="modal" role="dialog" aria-modal="true">
<div class="h">
<div class="ttl">${this.mTitle}</div>
<button class="close" @click=${() => this.close()}>×</button>
</div>
<div class="body" .innerHTML=${this.mBody}></div>
<div class="f">
${this.buttons.map((b) => html`
<button class=${b.variant ?? ''} @click=${() => this.clickBtn(b)}>${b.label}</button>
`)}
</div>
</div>
`;
}
}
export function openModal(req: ModalReq): void {
window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
}

View File

@ -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: '<p>Clears the frame stream and rewinds <code>t</code> to 0.</p>',
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: `<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px;">
<div><code>K / Ctrl K</code></div><div>Command palette</div>
<div><code>Space</code></div><div>Play / pause</div>
<div><code>R</code></div><div>Reset</div>
<div><code>,</code></div><div>Settings</div>
<div><code>/</code></div><div>Toggle theme</div>
<div><code>\`</code></div><div>Debug HUD</div>
<div><code>1 · 2 · 3</code></div><div>Inspector tabs</div>
<div><code>Esc</code></div><div>Close modal/palette</div>
<div><code>/</code></div><div>Focus REPL</div>
</div>`,
buttons: [{ label: 'Close', variant: 'primary' }],
}) },
{ ico: 'i', label: 'About nvsim…', run: () => openModal({
title: 'About nvsim',
body: `<p><b>nvsim</b> is a deterministic, byte-reproducible forward simulator for nitrogen-vacancy diamond magnetometry.</p>
<p>This dashboard runs nvsim as WASM in a Web Worker. Same <code>(scene, config, seed)</code> byte-identical SHA-256 witness across runs and machines.</p>
<p>License: MIT OR Apache-2.0 · See ADR-089, ADR-092.</p>`,
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`
<div class="palette" data-id="palette">
<div class="input">
<input id="palette-input" type="text" placeholder="Type a command…"
.value=${this.filter}
@input=${(e: Event) => { this.filter = (e.target as HTMLInputElement).value; this.idx = 0; }} />
</div>
<div class="list">
${items.map((c, i) => html`
<div class="item ${i === this.idx ? 'active' : ''}" @click=${() => { this.idx = i; this.runIdx(); }}>
<span class="ico">${c.ico}</span>
<span class="lbl">${c.label}</span>
${c.kbd ? html`<span class="kbd">${c.kbd}</span>` : ''}
</div>
`)}
</div>
</div>
`;
}
}

View File

@ -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`
<div class="logo">NV</div>
<button class="btn ${this.view === 'scene' ? 'active' : ''}" data-id="scene-btn" title="Scene"
@click=${() => this.navigate('scene')}>
<svg viewBox="0 0 24 24"><path d="M12 2L3 7l9 5 9-5-9-5zm0 13l-9-5v6l9 5 9-5v-6l-9 5z"/></svg>
</button>
<button class="btn ${this.view === 'apps' ? 'active' : ''}" data-id="apps-btn" title="App Store"
@click=${() => this.navigate('apps')}>
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
</button>
<button class="btn" title="Inspector" @click=${() => this.navigate('scene')}>
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
</button>
<button class="btn" title="Witness">
<svg viewBox="0 0 24 24"><path d="M9 12l2 2 4-4M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"/></svg>
</button>
<div class="spacer"></div>
<button class="btn" data-id="settings-btn" title="Settings"
@click=${() => this.dispatchEvent(new CustomEvent('open-settings', { bubbles: true, composed: true }))}>
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06A1.65 1.65 0 0015 19.4a1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</button>
`;
}
}

View File

@ -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`
<div class="grid"></div>
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid meet" id="scene-svg">
<defs>
<radialGradient id="g-sensor" cx="50%" cy="50%" r="50%">
<stop offset="0" stop-color="oklch(0.78 0.14 70)" stop-opacity="0.4"/>
<stop offset="1" stop-color="oklch(0.78 0.14 70)" stop-opacity="0"/>
</radialGradient>
<filter id="glow"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<!-- Field lines from each source to sensor -->
${this.items.map((it) => svg`
<line class="field-line ${animClass}" x1=${it.x} y1=${it.y}
x2="500" y2="320"
stroke=${it.color} stroke-width="1" stroke-opacity="0.5"/>
`)}
<!-- Source primitives -->
${this.items.map((it) => svg`
<g class=${`draggable ${this.dragging === it.id ? 'dragging' : ''} ${this.selected === it.id ? 'selected' : ''}`}
data-id=${it.id} data-source-id=${it.id}
transform=${`translate(${it.x.toFixed(0)},${it.y.toFixed(0)})`}
@pointerdown=${(e: PointerEvent) => this.onDown(it.id, e)}>
<ellipse cx="0" cy="0" rx="32" ry="22" fill=${it.color} fill-opacity="0.18"
stroke=${it.color} stroke-width="1.2"/>
<circle cx="0" cy="0" r="4" fill=${it.color}/>
<text class="label" x="0" y="40" text-anchor="middle">${it.name}</text>
</g>
`)}
<!-- Sensor (NV diamond) at center -->
<g id="sensor-g" class="draggable" data-id="sensor" transform="translate(500, 320)">
<circle cx="0" cy="0" r="46" fill="url(#g-sensor)"/>
<g class=${`crystal ${animClass}`} stroke="oklch(0.78 0.14 70)" stroke-width="2"
fill="oklch(0.78 0.14 70 / 0.08)" filter="url(#glow)">
<polygon points="0,-22 19,-7 12,18 -12,18 -19,-7"/>
</g>
<circle cx="0" cy="0" r="3" fill="var(--accent)"/>
<text class="label" x="0" y="56" text-anchor="middle">
sensor · 111 NV
</text>
<text class="label" x="0" y="72" text-anchor="middle">
B_in: <tspan fill="var(--accent)" id="b-in-svg">[${bnT[0].toFixed(2)}, ${bnT[1].toFixed(2)}, ${bnT[2].toFixed(2)}] nT</tspan>
</text>
</g>
</svg>
<div class="scene-readout">
<div class="stat-card">
<div class="lbl">|B|</div>
<div class="val amber" id="bmag-readout">${bMagNT.toFixed(3)} nT</div>
</div>
<div class="stat-card">
<div class="lbl">FPS</div>
<div class="val cyan" id="fps-readout">${fps.value > 0 ? Math.round(fps.value) : '—'}</div>
</div>
<div class="stat-card">
<div class="lbl">SNR</div>
<div class="val mint" id="snr-readout">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</div>
</div>
</div>
`;
}
}

View File

@ -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`
<div class="scrim" @click=${() => this.close()}></div>
<div class="h">
<div class="ttl">Settings</div>
<button class="close" @click=${() => this.close()}>×</button>
</div>
<div class="body">
<div class="group">
<h4>Appearance</h4>
<div class="row">
<div><div class="lbl">Theme</div></div>
<div class="seg">
<button class=${theme.value === 'dark' ? 'on' : ''} @click=${() => theme.value = 'dark'}>dark</button>
<button class=${theme.value === 'light' ? 'on' : ''} @click=${() => theme.value = 'light'}>light</button>
</div>
</div>
<div class="row">
<div>
<div class="lbl">Density</div>
<div class="desc">Affects panel padding and font scale.</div>
</div>
<div class="seg">
<button class=${density.value === 'comfy' ? 'on' : ''} @click=${() => density.value = 'comfy'}>comfy</button>
<button class=${density.value === 'default' ? 'on' : ''} @click=${() => density.value = 'default'}>default</button>
<button class=${density.value === 'compact' ? 'on' : ''} @click=${() => density.value = 'compact'}>compact</button>
</div>
</div>
<div class="row">
<div>
<div class="lbl">Reduce motion</div>
<div class="desc">Disable rotating crystal & field-line animation.</div>
</div>
<span class="toggle ${motionReduced.value ? 'on' : ''}"
@click=${() => motionReduced.value = !motionReduced.value}></span>
</div>
</div>
<div class="group">
<h4>Pipeline</h4>
<div class="row">
<div><div class="lbl">Auto-rerun on edit</div>
<div class="desc">Restart pipeline when scene/config changes.</div></div>
<span class="toggle ${autoUpdate.value ? 'on' : ''}"
@click=${() => autoUpdate.value = !autoUpdate.value}></span>
</div>
</div>
<div class="group">
<h4>Transport</h4>
<div class="row">
<div><div class="lbl">Mode</div></div>
<div class="seg">
<button class=${transport.value === 'wasm' ? 'on' : ''} @click=${() => transport.value = 'wasm'}>WASM</button>
<button class=${transport.value === 'ws' ? 'on' : ''} @click=${() => transport.value = 'ws'}>WS</button>
</div>
</div>
${transport.value === 'ws' ? html`
<div class="row">
<div><div class="lbl">WS URL</div></div>
<input type="text" placeholder="ws://localhost:7878" .value=${wsUrl.value}
@input=${(e: Event) => wsUrl.value = (e.target as HTMLInputElement).value} />
</div>` : ''}
</div>
</div>
`;
}
}

View File

@ -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`
<div class="panel">
<div class="panel-h">Scene <span class="count">4 sources</span></div>
<div class="scene-item">
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
<span class="name">rebar.steel.coil</span>
<span class="meta">χ=5000</span>
</div>
<div class="scene-item">
<span class="swatch" style="background:oklch(0.78 0.14 195)"></span>
<span class="name">heart_proxy</span>
<span class="meta">1e-6 A·m²</span>
</div>
<div class="scene-item">
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
<span class="name">mains_60Hz</span>
<span class="meta">2 A · 60 Hz</span>
</div>
<div class="scene-item">
<span class="swatch" style="background:oklch(0.78 0.14 145)"></span>
<span class="name">door.steel</span>
<span class="meta">eddy</span>
</div>
</div>
<div class="panel">
<div class="panel-h">NV sensor <span class="count">COTS</span></div>
<div class="field-row"><span class="lbl">V</span><span class="val">1 mm³</span></div>
<div class="field-row"><span class="lbl">N</span><span class="val">1e12 NV</span></div>
<div class="field-row"><span class="lbl">C</span><span class="val">0.030</span></div>
<div class="field-row"><span class="lbl">T*</span><span class="val">200 ns</span></div>
<div class="field-row"><span class="lbl">δB</span><span class="val">1.18 pT/Hz</span></div>
</div>
<div class="panel">
<div class="panel-h">Tunables</div>
<div class="slider-row">
<div class="top"><span class="lbl">Sample rate</span><span class="val">${(fs.value / 1000).toFixed(1)} kHz</span></div>
<input type="range" min="1000" max="100000" .value=${String(fs.value)}
@input=${(e: Event) => fs.value = +(e.target as HTMLInputElement).value} />
</div>
<div class="slider-row">
<div class="top"><span class="lbl">Lockin f_mod</span><span class="val">${(fmod.value / 1000).toFixed(3)} kHz</span></div>
<input type="range" min="100" max="5000" .value=${String(fmod.value)}
@input=${(e: Event) => fmod.value = +(e.target as HTMLInputElement).value} />
</div>
<div class="slider-row">
<div class="top"><span class="lbl">Integration t</span><span class="val">${dtMs.value.toFixed(1)} ms</span></div>
<input type="range" min="0.1" max="10" step="0.1" .value=${String(dtMs.value)}
@input=${(e: Event) => dtMs.value = +(e.target as HTMLInputElement).value} />
</div>
<div class="slider-row">
<div class="top"><span class="lbl">Shot noise</span><span class="val">${noiseEnabled.value ? 'ON' : 'OFF'}</span></div>
<input type="range" min="0" max="1" .value=${noiseEnabled.value ? '1' : '0'}
@input=${(e: Event) => noiseEnabled.value = (e.target as HTMLInputElement).value === '1'} />
</div>
</div>
<div class="panel">
<div class="panel-h">Pipeline</div>
<div class="pipeline">
<span class="stage ${running.value ? 'live' : ''}">scene</span>
<span class="stage-arrow"></span>
<span class="stage ${running.value ? 'live' : ''}">B-S</span>
<span class="stage-arrow"></span>
<span class="stage ${running.value ? 'live' : ''}">prop</span>
<span class="stage-arrow"></span>
<span class="stage ${running.value ? 'live' : ''}">NV</span>
<span class="stage-arrow"></span>
<span class="stage ${running.value ? 'live' : ''}">ADC</span>
<span class="stage-arrow"></span>
<span class="stage ${running.value ? 'live' : ''}">frame</span>
</div>
</div>
`;
}
}

View File

@ -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`<span class="icon">${this.icon}</span><span>${this.msg}</span>`;
}
}
export function toast(msg: string, icon = '✓'): void {
window.dispatchEvent(new CustomEvent('nv-toast', { detail: { msg, icon } }));
}

View File

@ -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<void> {
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<void> {
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`
<div class="crumbs">
<span class="home">RuView</span><span class="sep">/</span>
<span>nvsim</span><span class="sep">/</span>
<span class="cur" id="scene-name">${sceneName.value}</span>
</div>
<div class="spacer"></div>
<span class="pill" id="fps-pill">
<span class="dot"></span>
<span id="fps-val">${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'idle'}</span>
</span>
<span class="pill wasm" id="transport-pill"><span class="dot"></span>${transportLabel.value}</span>
<span class="pill seed" id="seed-pill">seed: <b>0x${seedHex}</b></span>
<button class="ghost" id="theme-btn" title="Toggle theme" @click=${this.toggleTheme}>
${theme.value === 'dark' ? '☼' : '☾'}
</button>
<button id="reset-btn" @click=${this.reset}> Reset</button>
<button class="primary" id="run-btn" @click=${this.toggleRun}>
${running.value ? '❚❚ Pause' : '▶ Run'}
</button>
`;
}
}

101
dashboard/src/main.ts Normal file
View File

@ -0,0 +1,101 @@
/* nvsim dashboard entry — boots the WasmClient, mounts <nv-app>. */
import './app.css';
import './components/nv-app';
import { effect } from '@preact/signals-core';
import { WasmClient } from './transport/WasmClient';
import {
setClient, transport, theme, density, motionReduced,
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
} 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<boolean>('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}`);
}
})();

View File

@ -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<TransportMode>('wasm');
export const wsUrl = signal<string>('');
export const connected = signal<boolean>(false);
export const transportError = signal<string | null>(null);
export const running = signal<boolean>(false);
export const paused = signal<boolean>(true);
export const speed = signal<number>(1.0);
export const t = signal<number>(0); // sim time (s)
export const framesEmitted = signal<bigint>(0n);
export const seed = signal<bigint>(0xCAFEBABEn);
export const fs = signal<number>(10000); // sample rate Hz
export const fmod = signal<number>(1000); // lockin Hz
export const dtMs = signal<number>(1.0);
export const noiseEnabled = signal<boolean>(true);
export const theme = signal<Theme>('dark');
export const density = signal<Density>('default');
export const motionReduced = signal<boolean>(false);
export const autoUpdate = signal<boolean>(true);
export const lastB = signal<[number, number, number]>([0, 0, 0]); // T
export const bMag = signal<number>(0);
export const snr = signal<number>(0);
export const fps = signal<number>(0);
export const witnessHex = signal<string>('');
export const witnessVerified = signal<'pending' | 'ok' | 'fail' | 'idle'>('idle');
export const expectedWitness = signal<string>('');
export const lastFrame = signal<MagFrameRecord | null>(null);
export const traceX = signal<number[]>([]);
export const traceY = signal<number[]>([]);
export const traceZ = signal<number[]>([]);
export const stripBars = signal<number[]>([]);
export const sceneName = signal<string>('rebar-walkby-01');
export const sceneJson = signal<string>('');
export const consolePaused = signal<boolean>(false);
export const consoleFilter = signal<'all' | 'info' | 'warn' | 'err' | 'dbg' | 'ok'>('all');
export const transportLabel = computed<string>(() =>
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<ConsoleLine[]>([]);
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.
}

309
dashboard/src/store/apps.ts Normal file
View File

@ -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 100199 Medical & health
* sec 200299 Security & safety
* bld 300399 Smart building
* ret 400499 Retail & hospitality
* ind 500599 Industrial
* sig 600619 Signal-processing primitives
* lrn 620639 Online learning
* spt 640659 Spatial / graph
* tmp 640660 Temporal logic / planning
* ais 700719 AI safety
* qnt 720739 Quantum-flavoured signal
* aut 740759 Autonomy / mesh
* exo 650699 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 → BiotSavart → 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<AppCategory, { label: string; color: string; range: string }> = {
sim: { label: 'Simulators', color: 'oklch(0.78 0.14 70)', range: '—' },
med: { label: 'Medical & Health', color: 'oklch(0.65 0.22 25)', range: '100199' },
sec: { label: 'Security & Safety', color: 'oklch(0.7 0.18 35)', range: '200299' },
bld: { label: 'Smart Building', color: 'oklch(0.78 0.12 195)', range: '300399' },
ret: { label: 'Retail & Hospitality', color: 'oklch(0.78 0.14 145)', range: '400499' },
ind: { label: 'Industrial', color: 'oklch(0.72 0.18 330)', range: '500599' },
sig: { label: 'Signal Processing', color: 'oklch(0.78 0.14 70)', range: '600619' },
lrn: { label: 'Online Learning', color: 'oklch(0.78 0.12 260)', range: '620639' },
spt: { label: 'Spatial / Graph', color: 'oklch(0.7 0.18 100)', range: '640659' },
tmp: { label: 'Temporal / Planning', color: 'oklch(0.7 0.16 50)', range: '660679' },
ais: { label: 'AI Safety', color: 'oklch(0.65 0.22 25)', range: '700719' },
qnt: { label: 'Quantum', color: 'oklch(0.72 0.18 290)', range: '720739' },
aut: { label: 'Autonomy', color: 'oklch(0.78 0.14 145)', range: '740759' },
exo: { label: 'Exotic / Research', color: 'oklch(0.72 0.18 330)', range: '650699' },
};
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<AppCategory, AppManifest[]> {
const map = {} as Record<AppCategory, AppManifest[]>;
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;
}

View File

@ -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<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((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<T = unknown>(key: string): Promise<T | undefined> {
const db = await openDb();
return await new Promise<T | undefined>((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<void> {
const db = await openDb();
return await new Promise<void>((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<void> {
const db = await openDb();
return await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@ -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<void>;
setConfig(cfg: PipelineConfigJson): Promise<void>;
setSeed(seed: bigint): Promise<void>;
reset(): Promise<void>;
run(opts?: RunOpts): Promise<void>;
pause(): Promise<void>;
step(direction: 'fwd' | 'back', dtMs: number): Promise<void>;
onFrames(cb: (batch: MagFrameBatch) => void): void;
onEvent(cb: (ev: NvsimEvent) => void): void;
generateWitness(samples: number): Promise<Uint8Array>;
verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }>;
exportProofBundle(): Promise<Blob>;
buildId(): Promise<string>;
close(): Promise<void>;
}
/** 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;
}

View File

@ -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<T = unknown> {
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<number, PendingRequest<unknown>>();
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<T = unknown>(msg: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.worker.postMessage({ ...msg, id }, transfer);
});
}
async boot(): Promise<WasmBootInfo> {
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<void> {
await this.rpc({ type: 'setScene', json: JSON.stringify(scene) });
}
async setConfig(cfg: PipelineConfigJson): Promise<void> {
await this.rpc({ type: 'setConfig', json: JSON.stringify(cfg) });
}
async setSeed(seed: bigint): Promise<void> {
await this.rpc({ type: 'setSeed', seed: Number(seed & 0xFFFFFFFFn) });
}
async reset(): Promise<void> {
await this.rpc({ type: 'reset' });
}
async run(_opts?: RunOpts): Promise<void> {
await this.rpc({ type: 'run' });
}
async pause(): Promise<void> {
await this.rpc({ type: 'pause' });
}
async step(_direction: 'fwd' | 'back', _dtMs: number): Promise<void> {
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<Uint8Array> {
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<Blob> {
// 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<string> {
const r = await this.rpc<{ buildId: string }>({ type: 'buildId' });
return r.buildId;
}
async close(): Promise<void> {
this.worker.terminate();
}
}

View File

@ -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.
*/
/// <reference lib="WebWorker" />
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<unknown>;
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<void> {
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<void> => {
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' });

25
dashboard/tsconfig.json Normal file
View File

@ -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"]
}

41
dashboard/vite.config.ts Normal file
View File

@ -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',
},
},
});

View File

@ -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 | 100199 | 6 | sleep_apnea, cardiac_arrhythmia, gait_analysis, seizure_detect, vital_trend |
| Security & Safety | 200299 | 5 | perimeter_breach, weapon_detect, tailgating, loitering, panic_motion |
| Smart Building | 300399 | 5 | hvac_presence, lighting_zones, elevator_count, meeting_room, energy_audit |
| Retail & Hospitality | 400499 | 5 | queue_length, dwell_heatmap, customer_flow, table_turnover, shelf_engagement |
| Industrial | 500599 | 5 | forklift_proximity, confined_space, clean_room, livestock_monitor, structural_vibration |
| Signal Processing | 600619 | 7 | gesture, coherence, rvf, flash_attention, sparse_recovery, mincut, optimal_transport |
| Online Learning | 620639 | 4 | dtw_gesture_learn, anomaly_attractor, meta_adapt, ewc_lifelong |
| Spatial / Graph | 640659 | 3 | pagerank_influence, micro_hnsw, spiking_tracker |
| Temporal / Planning | 660679 | 3 | pattern_sequence, temporal_logic_guard, goap_autonomy |
| AI Safety | 700719 | 3 | adversarial, prompt_shield, behavioral_profiler |
| Quantum | 720739 | 2 | quantum_coherence, interference_search |
| Autonomy / Mesh | 740759 | 2 | psycho_symbolic, self_healing_mesh |
| Exotic / Research | 650699 | 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 550 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/<id>.rs`.
2. Add `pub mod <id>;` 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)

View File

@ -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 }

View File

@ -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::{

160
v2/crates/nvsim/src/wasm.rs Normal file
View File

@ -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<str>) -> JsValue {
JsValue::from_str(msg.as_ref())
}
/// In-browser pipeline. Wraps [`Pipeline`] with JS-friendly construction
/// (JSON for `Scene` and `PipelineConfig`) and `Vec<u8>` 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<WasmPipeline, JsValue> {
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<u8> {
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<JsValue, JsValue> {
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<String, JsValue> {
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<js_sys::Uint8Array, JsValue> {
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)
}