feat(nvsim): full simulator stack — Rust crate, dashboard, server, App Store, Ghost Murmur [ADR-089/090/091/092/093]

Squashed merge of feat/nvsim-pipeline-simulator (29 commits).

## Shipped

- ADR-089 nvsim crate (Accepted) — 50/50 tests, ~4.5 M samples/s, pinned witness cc8de9b01b0ff5bd…
- ADR-092 dashboard implementation (Implemented) — 8/12 §11 gates , 4/12 ⚠ (external infra)
- ADR-093 dashboard gap analysis (Implemented) — 21/21 catalogued gaps closed
- Plus ADR-090 (proposed conditional) and ADR-091 (proposed research-only)

## Live deploy
https://ruvnet.github.io/RuView/nvsim/

## Infra

- nvsim-server Dockerfile + GHCR publish workflow (.github/workflows/nvsim-server-docker.yml)
- axe-core + Playwright cross-browser CI (.github/workflows/dashboard-a11y.yml)
- gh-pages auto-deploy workflow already in place (preserves observatory + pose-fusion siblings)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-04-27 12:41:01 -04:00
committed by GitHub
parent 905b680747
commit 7f5a692632
70 changed files with 20533 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.vite
*.log
public/nvsim-pkg
+18
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>
+6525
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"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",
"test:e2e": "playwright test",
"test:a11y": "playwright test tests/a11y.spec.ts"
},
"dependencies": {
"@preact/signals-core": "^1.8.0",
"lit": "^3.2.1",
"workbox-window": "^7.4.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@playwright/test": "^1.59.1",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.4"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://localhost:4173',
headless: true,
},
webServer: {
command: 'npm run preview',
port: 4173,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" rx="36" fill="#e6a86b"/>
<text x="96" y="124" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="80" fill="#1a0f00">NV</text>
</svg>

After

Width:  |  Height:  |  Size: 313 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#e6a86b"/>
<stop offset="1" stop-color="#a4633a"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#g)"/>
<text x="256" y="332" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="220" fill="#1a0f00">NV</text>
</svg>

After

Width:  |  Height:  |  Size: 504 B

+92
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: #d8dde3;
--line-2: #c1c8d1;
--ink: #0e131a;
--ink-2: #2c3744;
--ink-3: #54606e; /* AA on --bg-1 #fbfbfc — was #6b7684 (3.7:1), now ~5.4:1 */
--ink-4: #7a8390; /* improved from #9ba4b0 for incidental UI labels */
--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; }
+399
View File
@@ -0,0 +1,399 @@
/* 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, activeAppIds, appEvents, appEventCounts } from '../store/appStore';
import { hasRuntime } from '../store/appRuntimes';
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) AND mirror into the
// active-set signal that main.ts watches to drive runtime dispatch.
const v = activations.value;
if (v.length > 0) void kvSet('app-activations', v);
const set = new Set<string>();
for (const a of v) if (a.active) set.add(a.id);
activeAppIds.value = set;
});
@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); }
.badge.rt-running { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.5); background: oklch(0.78 0.14 145 / 0.08); }
.badge.rt-simulated { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.5); background: oklch(0.78 0.14 70 / 0.08); }
.badge.rt-mesh-only { color: var(--ink-3); border-color: var(--line); }
.events-feed {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 18px;
}
.events-feed h3 {
margin: 0 0 8px;
font-size: 13px; font-weight: 600;
color: var(--ink);
}
.events-feed .lead {
font-size: 12px; color: var(--ink-3);
margin: 0 0 10px;
line-height: 1.5;
}
.events-feed .lines {
display: flex; flex-direction: column; gap: 4px;
max-height: 160px; overflow-y: auto;
}
.ev-line {
display: grid;
grid-template-columns: 60px 90px 1fr;
gap: 10px;
padding: 4px 6px;
border-radius: 4px;
font-family: var(--mono);
font-size: 11px;
color: var(--ink-2);
}
.ev-line:hover { background: var(--bg-3); }
.ev-line .ts { color: var(--ink-4); font-size: 10.5px; }
.ev-line .id { color: var(--accent); font-size: 10.5px; }
.ev-line .body { color: var(--ink); }
.ev-empty {
font-size: 12px; color: var(--ink-3);
padding: 8px 0;
}
.card-events-count {
font-size: 10.5px;
color: var(--accent-4);
font-family: var(--mono);
}
.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;
appEvents.value; appEventCounts.value;
this.renderTick++;
});
}
private isActive(id: string): boolean {
return activations.value.find((a) => a.id === id)?.active === true;
}
private toggle(app: AppManifest): void {
const wasActive = this.isActive(app.id);
const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a);
activations.value = next;
if (!wasActive) {
const r = app.runtime ?? 'mesh-only';
const note = r === 'simulated' ? ' · live runtime engaged'
: r === 'mesh-only' ? ' · queued (needs ESP32 mesh)'
: '';
pushLog('ok', `app <span class="k">${app.id}</span> activated${note}`);
} else {
pushLog('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>
${this.renderEventsFeed()}
${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 renderEventsFeed() {
const evs = appEvents.value.slice(-12).reverse();
const activeSimCount = activations.value.filter((a) => a.active && hasRuntime(a.id)).length;
return html`
<div class="events-feed">
<h3>Live runtime feed
${activeSimCount > 0
? html`<span class="card-events-count" style="margin-left: 8px;">${activeSimCount} simulated app${activeSimCount === 1 ? '' : 's'} active</span>`
: ''}
</h3>
<p class="lead">
Apps with the <span class="badge rt-simulated" style="font-size:9.5px; padding:0 4px;">simulated</span>
runtime emit real i32 event IDs against nvsim's live frame stream below.
Apps with <span class="badge rt-mesh-only" style="font-size:9.5px; padding:0 4px;">mesh-only</span>
need an ESP32-S3 + WS transport (deferred to V2). The
<span class="badge rt-running" style="font-size:9.5px; padding:0 4px;">running</span>
badge marks <code>nvsim</code> itself, which is always running.
</p>
${evs.length === 0
? html`<div class="ev-empty">No events yet. Toggle a card with the <i>simulated</i> badge and press <b>▶ Run</b>.</div>`
: html`<div class="lines">${evs.map((ev) => {
const dt = new Date(ev.ts);
const ts = `${String(dt.getSeconds()).padStart(2, '0')}.${String(dt.getMilliseconds()).padStart(3, '0')}`;
return html`
<div class="ev-line">
<span class="ts">${ts}</span>
<span class="id">${ev.appId}</span>
<span class="body"><b style="color:var(--accent-2);">${ev.eventName}</b><span style="color:var(--ink-3);"> · ${ev.eventId}</span> ${ev.detail ? `· ${ev.detail}` : ''}</span>
</div>
`;
})}</div>`}
</div>
`;
}
private card(app: AppManifest) {
const active = this.isActive(app.id);
const cat = CATEGORIES[app.category];
const runtime = app.runtime ?? 'mesh-only';
const evCount = appEventCounts.value[app.id] ?? 0;
const runtimeLabel: Record<string, string> = {
'running': 'running',
'simulated': 'simulated',
'mesh-only': 'needs mesh',
};
const runtimeTip: Record<string, string> = {
'running': 'This app is genuinely running in your browser right now.',
'simulated': 'A pared-down version of this algorithm runs against nvsim\'s magnetic frame stream as a proxy for its native CSI input. Toggle on, then press ▶ Run to see real event IDs in the feed.',
'mesh-only': 'This algorithm needs CSI subcarrier data from an ESP32-S3 mesh. The toggle persists; activation is pushed via WS transport (V2).',
};
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>
<span class="badge rt-${runtime}" title=${runtimeTip[runtime]}>${runtimeLabel[runtime]}</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>
${evCount > 0 ? html`<span class="card-events-count">⚡ ${evCount} ev</span>` : ''}
<span class="toggle ${active ? 'on' : ''}" role="switch"
aria-checked=${active}
data-app-toggle=${app.id}
@click=${() => this.toggle(app)}></span>
</div>
</div>
`;
}
}
+143
View File
@@ -0,0 +1,143 @@
/* 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';
import './nv-onboarding';
import './nv-ghost-murmur';
import './nv-help';
import './nv-home';
export type View = 'home' | 'scene' | 'apps' | 'inspector' | 'witness' | 'ghost-murmur';
@customElement('nv-app')
export class NvApp extends LitElement {
@state() private view: View = 'home';
static styles = css`
:host {
display: block;
height: 100vh;
width: 100vw;
background: var(--bg-0);
}
.skip-link {
position: absolute;
top: -40px;
left: 8px;
padding: 6px 12px;
background: var(--accent);
color: #1a0f00;
border-radius: 6px;
font-size: 12.5px;
font-weight: 600;
text-decoration: none;
z-index: 1000;
transition: top 0.15s;
}
.skip-link:focus { top: 8px; }
.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;
}
/* Home view simplifies: hides sidebar / inspector / console so the
hero gets the full screen. Power-user panels stay one rail click away. */
.app.simple {
grid-template-columns: 56px 1fr;
grid-template-rows: 48px 1fr;
grid-template-areas:
'rail topbar'
'rail main';
}
.app.simple nv-sidebar,
.app.simple nv-inspector,
.app.simple nv-console { display: none; }
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() {
const isSimple = this.view === 'home';
return html`
<a class="skip-link" href="#main-content"
@click=${(e: Event) => { e.preventDefault(); const sr = this.shadowRoot; sr?.querySelector<HTMLElement>('.main')?.focus(); }}>
Skip to main content
</a>
<div class="app ${isSimple ? 'simple' : ''}">
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
<nv-topbar></nv-topbar>
<nv-sidebar></nv-sidebar>
<main class="main" id="main-content" tabindex="-1" role="main" aria-label="Main view">
${this.view === 'home'
? html`<nv-home></nv-home>`
: this.view === 'apps'
? html`<nv-app-store></nv-app-store>`
: this.view === 'ghost-murmur'
? html`<nv-ghost-murmur></nv-ghost-murmur>`
: this.view === 'inspector'
? html`<nv-inspector expanded .pinTab=${'signal'}></nv-inspector>`
: this.view === 'witness'
? html`<nv-inspector expanded .pinTab=${'witness'}></nv-inspector>`
: html`<nv-scene></nv-scene>`}
</main>
<nv-inspector
.pinTab=${this.view === 'inspector' ? 'signal'
: this.view === 'witness' ? 'witness' : null}>
</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>
<nv-onboarding></nv-onboarding>
<nv-help></nv-help>
`;
}
}
+266
View File
@@ -0,0 +1,266 @@
/* 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, replHistory, pushReplHistory,
} from '../store/appStore';
@customElement('nv-console')
export class NvConsole extends LitElement {
@query('#console-input') private inputEl!: HTMLInputElement;
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}`);
pushReplHistory(line);
this.hIdx = replHistory.value.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 · reset · seed · proof.verify · proof.export · 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 'proof.export': {
if (!c) break;
pushLog('dbg', 'building proof bundle…');
try {
const blob = await c.exportProofBundle();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nvsim-proof-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
pushLog('ok', `proof bundle exported · ${blob.size} bytes`);
} catch (e) { pushLog('err', `export 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') {
const h = replHistory.value;
if (h.length) {
this.hIdx = Math.max(0, this.hIdx - 1);
this.inputEl.value = h[this.hIdx] ?? '';
e.preventDefault();
}
} else if (e.key === 'ArrowDown') {
const h = replHistory.value;
if (h.length) {
this.hIdx = Math.min(h.length, this.hIdx + 1);
this.inputEl.value = h[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" role="log" aria-live="polite" aria-label="Console output">
${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>
`;
}
}
+88
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>
`;
}
}
+666
View File
@@ -0,0 +1,666 @@
/* Ghost Murmur — research view.
*
* Walks through the publicly-reported April 2026 CIA program and maps
* the physically-defensible parts onto RuView's three-tier heartbeat
* mesh. Source: docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md
*
* This view is reference material, not an operational mode. It exists
* so practitioners (and journalists) can audit the physics-vs-press
* gap in the open. ADR-092 §14b.
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { getClient, pushLog } from '../store/appStore';
import type { TransientRunResult } from '../transport/NvsimClient';
// Tier detection thresholds — order-of-magnitude floor each transport
// can resolve cardiac signal at, in Tesla. Source: Ghost Murmur spec
// §4.7, Wolf 2015, Barry 2020. These are deliberately optimistic for the
// "available" path; the shoot-the-moon press claim sits 6+ orders below.
const TIERS = [
{ id: 'nvBest', label: 'NV-ensemble (best lab)', floorT: 1e-12, color: 'oklch(0.78 0.14 70)' },
{ id: 'nvCots', label: 'NV-DNV-B1 (COTS)', floorT: 3e-10, color: 'oklch(0.72 0.18 50)' },
{ id: 'squid', label: 'SQUID (shielded room)', floorT: 1e-15, color: 'oklch(0.78 0.12 195)' },
{ id: 'mmw', label: '60 GHz mmWave (μ-Doppler)', floorT: 0, color: 'oklch(0.78 0.14 145)' },
{ id: 'csi', label: 'WiFi CSI (presence)', floorT: 0, color: 'oklch(0.72 0.18 330)' },
];
// Cardiac dipole moment (A·m²) — order-of-magnitude estimate from
// Wikswo / Bison cardiac MCG modelling.
const HEART_DIPOLE_AM2 = 5e-9;
@customElement('nv-ghost-murmur')
export class NvGhostMurmur extends LitElement {
@state() private distanceM = 0.1;
@state() private momentLog10 = -8.3; // log10(5e-9)
@state() private result: TransientRunResult | null = null;
@state() private running = false;
@state() private err: string | null = null;
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 28px 60px;
}
h1 {
margin: 0 0 4px;
font-size: 22px;
letter-spacing: -0.02em;
color: var(--ink);
}
.subtitle {
color: var(--ink-3);
font-size: 13px;
margin-bottom: 22px;
}
.links {
display: flex; flex-wrap: wrap; gap: 6px;
margin-bottom: 22px;
}
.links a {
padding: 5px 10px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11.5px;
font-family: var(--mono);
color: var(--accent-2);
text-decoration: none;
}
.links a:hover { border-color: var(--accent-2); }
h2 {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-3);
margin: 28px 0 10px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
}
.card {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
.card h3 {
margin: 0 0 8px;
font-size: 13.5px; font-weight: 600;
color: var(--ink);
}
.card p {
font-size: 12.5px; color: var(--ink-2);
margin: 0 0 8px;
line-height: 1.5;
}
.card p:last-child { margin-bottom: 0; }
.stat {
display: inline-flex; align-items: baseline; gap: 6px;
margin-right: 10px;
}
.stat .v {
font-family: var(--mono); font-size: 16px; font-weight: 600;
color: var(--accent);
}
.stat .l {
font-size: 10px; color: var(--ink-3);
text-transform: uppercase; letter-spacing: 0.04em;
}
table {
width: 100%; border-collapse: collapse;
font-size: 12.5px;
}
th, td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--ink-3);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
td.amber { color: var(--accent); font-family: var(--mono); }
td.cyan { color: var(--accent-2); font-family: var(--mono); }
td.bad { color: var(--bad); font-family: var(--mono); }
.pill {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-family: var(--mono);
font-size: 10px;
border: 1px solid var(--line);
}
.pill.ok { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
.pill.skeptical { color: var(--bad); border-color: oklch(0.65 0.22 25 / 0.4); }
.pill.partial { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
.architecture {
font-family: var(--mono);
font-size: 11px;
color: var(--ink-2);
background: var(--bg-3);
padding: 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
white-space: pre;
overflow-x: auto;
line-height: 1.4;
}
.ethics {
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.65 0.22 25 / 0.04) 100%);
border: 1px solid oklch(0.65 0.22 25 / 0.25);
border-radius: var(--radius);
padding: 16px;
}
.ethics h3 { color: var(--bad); margin-top: 0; }
.ethics ul { padding-left: 18px; margin: 8px 0; }
.ethics li { font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; }
/* Demo */
.demo {
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
border: 1px solid oklch(0.78 0.14 70 / 0.3);
border-radius: var(--radius);
padding: 18px;
}
.demo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 12px;
}
@media (max-width: 720px) { .demo-grid { grid-template-columns: 1fr; } }
.control { margin-bottom: 14px; }
.control .top {
display: flex; justify-content: space-between;
font-size: 12px; margin-bottom: 6px;
}
.control .top .lbl { color: var(--ink-3); }
.control .top .val {
font-family: var(--mono); color: var(--ink);
}
.control input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 4px;
background: var(--bg-3); border-radius: 2px; outline: none;
}
.control 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);
}
.demo-btn {
width: 100%;
padding: 10px;
border: 1px solid var(--accent);
background: var(--accent);
color: #1a0f00;
border-radius: 8px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.demo-btn:hover { filter: brightness(1.08); }
.demo-btn:disabled { opacity: 0.6; cursor: progress; }
.readout {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
}
.readout-row {
display: flex; justify-content: space-between;
padding: 4px 0;
font-family: var(--mono); font-size: 12px;
}
.readout-row .l { color: var(--ink-3); }
.readout-row .v { color: var(--ink); }
.readout-row .v.amber { color: var(--accent); }
.tier-bar {
position: relative;
margin: 6px 0;
height: 22px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.tier-bar .fill {
position: absolute; top: 0; bottom: 0; left: 0;
transition: width 0.2s ease-out;
border-right: 2px solid;
}
.tier-bar .lbl {
position: relative; z-index: 1;
font-family: var(--mono); font-size: 11px;
padding: 3px 8px;
color: var(--ink);
display: flex; justify-content: space-between;
pointer-events: none;
}
.verdict {
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
font-size: 12.5px; font-weight: 500;
border: 1px solid;
}
.verdict.ok { background: oklch(0.78 0.14 145 / 0.08); border-color: oklch(0.78 0.14 145 / 0.4); color: var(--ok); }
.verdict.warn { background: oklch(0.7 0.18 35 / 0.08); border-color: oklch(0.7 0.18 35 / 0.4); color: var(--warn); }
.verdict.bad { background: oklch(0.65 0.22 25 / 0.08); border-color: oklch(0.65 0.22 25 / 0.4); color: var(--bad); }
.demo-notes {
font-size: 11.5px; color: var(--ink-3);
margin-top: 10px; line-height: 1.5;
}
`;
/**
* Predicted MCG dipole field (Tesla) at distance r in metres.
* Far-field approximation: |B| ≈ μ₀ · m / (4π · r³). Source: Jackson 3e §5.
*/
private predictedDipoleFieldT(r: number, m: number): number {
const MU_0 = 4 * Math.PI * 1e-7;
return (MU_0 * m) / (4 * Math.PI * Math.pow(Math.max(r, 1e-6), 3));
}
private async runDemo(): Promise<void> {
const c = getClient();
if (!c) { this.err = 'WASM client not ready'; return; }
this.err = null;
this.running = true;
this.requestUpdate();
try {
const r = this.distanceM;
const m = Math.pow(10, this.momentLog10);
// Heart proxy at +z = r, dipole moment along z = m A·m².
const scene = {
dipoles: [{ position: [0, 0, r] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
loops: [],
ferrous: [],
eddy: [],
sensors: [[0, 0, 0] as [number, number, number]],
ambient_field: [0, 0, 0] as [number, number, number],
};
const config = {
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,
};
this.result = await c.runTransient(scene, config, 42n, 64);
pushLog('ok', `ghost-demo · r=${r.toFixed(3)} m · |B| recovered = ${(this.result.bMagT * 1e12).toExponential(2)} pT`);
} catch (e) {
this.err = (e as Error).message;
pushLog('err', `ghost-demo failed: ${this.err}`);
} finally {
this.running = false;
this.requestUpdate();
}
}
private formatField(t: number): string {
if (t === 0) return '0 T';
const abs = Math.abs(t);
if (abs >= 1e-3) return `${(t * 1e3).toFixed(2)} mT`;
if (abs >= 1e-6) return `${(t * 1e6).toFixed(2)} µT`;
if (abs >= 1e-9) return `${(t * 1e9).toFixed(3)} nT`;
if (abs >= 1e-12) return `${(t * 1e12).toFixed(2)} pT`;
if (abs >= 1e-15) return `${(t * 1e15).toFixed(2)} fT`;
if (abs >= 1e-18) return `${(t * 1e18).toFixed(2)} aT`;
return `${t.toExponential(2)} T`;
}
private formatDistance(r: number): string {
if (r < 1) return `${(r * 100).toFixed(1)} cm`;
if (r < 1000) return `${r.toFixed(2)} m`;
if (r < 1e5) return `${(r / 1000).toFixed(2)} km`;
return `${(r / 1609).toFixed(0)} mi`;
}
private renderDemo() {
const m = Math.pow(10, this.momentLog10);
const predicted = this.predictedDipoleFieldT(this.distanceM, m);
const recovered = this.result?.bMagT ?? 0;
const noiseFloor = (this.result?.noiseFloorPtSqrtHz ?? 0) * 1e-12; // pT/√Hz → T/√Hz
const verdictPills = TIERS.map((t) => {
let detect: 'ok' | 'warn' | 'bad' = 'bad';
let label = 'below floor';
if (t.id === 'mmw') {
if (this.distanceM <= 5) { detect = 'ok'; label = 'µ-Doppler @ chest'; }
else if (this.distanceM <= 15) { detect = 'warn'; label = 'edge of range'; }
else { detect = 'bad'; label = 'out of range'; }
} else if (t.id === 'csi') {
if (this.distanceM <= 30) { detect = this.distanceM <= 10 ? 'ok' : 'warn'; label = 'presence/breathing'; }
else { detect = 'bad'; label = 'out of range'; }
} else if (t.floorT > 0) {
const ratio = predicted / t.floorT;
if (ratio > 100) { detect = 'ok'; label = `${ratio.toExponential(1)}× floor`; }
else if (ratio > 1) { detect = 'warn'; label = `${ratio.toFixed(1)}× floor`; }
else { detect = 'bad'; label = `${(1 / ratio).toExponential(1)}× too weak`; }
}
const fillPct = t.floorT > 0
? Math.max(2, Math.min(100, 100 + 12 * Math.log10(predicted / t.floorT)))
: (t.id === 'mmw' ? Math.max(2, 100 - this.distanceM * 7) : Math.max(2, 100 - this.distanceM * 2));
return html`
<div class="tier-bar" data-tier=${t.id}>
<div class="fill" style=${`width:${fillPct}%; background:${t.color}; border-color:${t.color}`}></div>
<div class="lbl">
<span>${t.label}</span>
<span class="verdict-${detect}" style=${`color:${detect === 'ok' ? 'var(--ok)' : detect === 'warn' ? 'var(--warn)' : 'var(--bad)'}`}>${label}</span>
</div>
</div>
`;
});
const overallDetect: 'ok' | 'warn' | 'bad' =
predicted > 1e-12 ? 'ok' : predicted > 1e-15 ? 'warn' : 'bad';
const overallText =
overallDetect === 'ok'
? `Above NV-ensemble lab floor — close-range MCG plausible at ${this.formatDistance(this.distanceM)}.`
: overallDetect === 'warn'
? `Below NV ensemble best, above SQUID — research-grade only at ${this.formatDistance(this.distanceM)}.`
: `Below every published instrument's noise floor at ${this.formatDistance(this.distanceM)}. Press-release physics.`;
return html`
<div class="demo">
<h3 style="margin: 0 0 6px;">Try it yourself</h3>
<div style="font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; line-height: 1.5;">
Place a cardiac dipole at variable distance from the NV sensor. The
dashboard runs the <i>real</i> nvsim Rust pipeline (compiled to WASM)
end-to-end and reports what each tier would actually detect. Same
determinism contract as the rest of the dashboard.
</div>
<div class="demo-grid">
<div>
<div class="control">
<div class="top">
<span class="lbl">Distance from sensor</span>
<span class="val" id="demo-dist-val">${this.formatDistance(this.distanceM)}</span>
</div>
<input type="range" id="demo-distance"
min="-2" max="5" step="0.05"
.value=${String(Math.log10(this.distanceM))}
@input=${(e: Event) => { this.distanceM = Math.pow(10, +(e.target as HTMLInputElement).value); }} />
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
10 cm → 100 km log scale
</div>
</div>
<div class="control">
<div class="top">
<span class="lbl">Heart dipole moment</span>
<span class="val" id="demo-moment-val">${m.toExponential(2)} A·m²</span>
</div>
<input type="range" id="demo-moment"
min="-10" max="-6" step="0.05"
.value=${String(this.momentLog10)}
@input=${(e: Event) => { this.momentLog10 = +(e.target as HTMLInputElement).value; }} />
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
published cardiac MCG ≈ 5×10⁻⁹ A·m²
</div>
</div>
<button class="demo-btn" id="demo-run-btn" ?disabled=${this.running}
@click=${() => this.runDemo()}>
${this.running ? 'Running nvsim…' : '▶ Run nvsim at this distance'}
</button>
${this.err ? html`<div class="verdict bad" style="margin-top: 10px;">Error: ${this.err}</div>` : ''}
</div>
<div>
<div class="readout">
<div class="readout-row">
<span class="l">Predicted |B| (1/r³)</span>
<span class="v amber" id="demo-predicted">${this.formatField(predicted)}</span>
</div>
<div class="readout-row">
<span class="l">Recovered |B| (nvsim)</span>
<span class="v" id="demo-recovered">${this.result ? this.formatField(recovered) : '—'}</span>
</div>
<div class="readout-row">
<span class="l">Sensor noise floor</span>
<span class="v" id="demo-floor">${this.result ? this.formatField(noiseFloor) + '/√Hz' : '—'}</span>
</div>
<div class="readout-row">
<span class="l">Frames run</span>
<span class="v" id="demo-frames">${this.result?.nFrames ?? '—'}</span>
</div>
<div class="readout-row">
<span class="l">Witness (this run)</span>
<span class="v" style="font-size: 10px;" id="demo-witness">${this.result?.witnessHex.slice(0, 16) ?? '—'}…</span>
</div>
</div>
<div style="margin-top: 14px;">
<div style="font-size: 11.5px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;">
Per-tier detectability
</div>
${verdictPills}
</div>
</div>
</div>
<div class="verdict ${overallDetect}" id="demo-verdict">${overallText}</div>
<div class="demo-notes">
The <code>predicted</code> value uses the closed-form magnetic-dipole
far field <code>|B| = μ₀·m / (4π·r³)</code>. The <code>recovered</code>
value comes from the same Rust pipeline that drives the Witness panel —
scene → Biot-Savart → NV ensemble → ADC → MagFrame. Use the moment
slider to ask "what if the heart were stronger?". Use the distance
slider to walk through 10 cm (clinical MCG), 1 m (close approach),
10 m (room-scale), 1 km (skeptic's range), and 65 km (the press claim).
</div>
</div>
`;
}
override render() {
return html`
<h1>Ghost Murmur — open-source reality check</h1>
<div class="subtitle">
The physics-vs-press audit for the publicly-reported April 2026
CIA NV-diamond heartbeat detector, and how RuView's existing
stack maps onto an honest, civilian version of the same idea.
</div>
<div class="links">
<a href="https://github.com/ruvnet/RuView/blob/feat/nvsim-pipeline-simulator/docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md" target="_blank" rel="noopener">
📄 Full spec (583 lines)
</a>
<a href="https://gist.github.com/ruvnet/e44d0c3f0ad10d9c4933a196a16d405c" target="_blank" rel="noopener">
✦ Public gist
</a>
<a href="https://github.com/ruvnet/RuView/issues/437" target="_blank" rel="noopener">
# Issue #437
</a>
<a href="https://www.scientificamerican.com/article/what-is-the-quantum-ghost-murmur-purportedly-used-in-iran-scientists/" target="_blank" rel="noopener">
↗ Scientific American
</a>
</div>
<h2>What the press reported</h2>
<div class="grid">
<div class="card">
<h3>The story</h3>
<p>3 Apr 2026: USAF F-15E pilot "Dude 44 Bravo" goes down in southern Iran during the regional exchange and evades for ~2 days.</p>
<p>President Trump publicly suggests detection from <b>40 miles away</b> on a mountainside at night; CIA Director Ratcliffe says "invisible to the enemy, but not to the CIA."</p>
</div>
<div class="card">
<h3>The named tech</h3>
<p><b>"Ghost Murmur"</b> — Lockheed Skunk Works system using NV defects in synthetic diamond + AI to extract a heartbeat from environmental noise.</p>
<p>Outlets: <i>Newsweek, Scientific American, Military.com, WION, Open The Magazine, Yahoo, Calcalist</i> + HN thread #47679241.</p>
</div>
<div class="card">
<h3>What physicists said</h3>
<p>Wikswo (Vanderbilt), Orzel (Union College), Roth (Oakland) — all pushing back hard.</p>
<p>"At 1 km, the heartbeat field drops to ~10⁻¹² of its 10 cm value." MCG-only at multi-mile range is <span class="pill skeptical">not consistent with published physics</span>.</p>
</div>
</div>
<h2>Live demo — nvsim WASM</h2>
${this.renderDemo()}
<h2>Physics reality check</h2>
<div class="card" style="padding: 6px 14px;">
<table>
<thead>
<tr><th>Distance</th><th>Cardiac MCG (peak QRS)</th><th>vs Earth field (~50 µT)</th></tr>
</thead>
<tbody>
<tr><td>10 cm</td><td class="amber">50 pT</td><td>10⁹× weaker</td></tr>
<tr><td>1 m</td><td class="amber">50 fT</td><td>10¹²× weaker</td></tr>
<tr><td>10 m</td><td class="cyan">50 aT</td><td>10¹⁵× weaker</td></tr>
<tr><td>1 km</td><td class="bad">5 × 10⁻²³ T</td><td>10²⁷× weaker</td></tr>
<tr><td>40 mi (65 km)</td><td class="bad">~10⁻²⁸ T</td><td>10³³× weaker</td></tr>
</tbody>
</table>
<p style="font-size: 12px; color: var(--ink-3); margin: 10px 0 0; line-height: 1.5;">
Best published NV-ensemble lab record: <b>0.9 pT/√Hz</b> [Wolf 2015].
Best SQUID in a shielded room: <b>~1 fT/√Hz</b>. To detect a single heartbeat at 10 m
you'd need ~2 billion× more sensitivity than any published ensemble has ever shown,
in a magnetically silent environment. <i>40 miles is press-release physics.</i>
</p>
</div>
<h2>RuView's three-tier mesh — what is actually buildable</h2>
<div class="architecture"> ┌──────────────────────────┐
│ Tier 3 — NV-diamond │ Range: 0.12 m (lab)
│ magnetometer ring │ Status: nvsim simulator only
│ (close-confirm) │ Hardware: $$$ (≥$8k DNV-B1)
└──────────┬───────────────┘
┌──────────┴───────────────┐
│ Tier 2 — 60 GHz FMCW │ Range: 110 m HR/BR
│ mmWave radar mesh │ Status: shipping (ADR-021)
│ (vital signs, posture) │ Hardware: $15 (MR60BHA2 + ESP32-C6)
└──────────┬───────────────┘
┌──────────┴───────────────┐
│ Tier 1 — WiFi CSI mesh │ Range: 1030 m through-wall
│ (presence, breathing, │ Status: shipping (ADR-014, ADR-029)
│ pose, intention) │ Hardware: $9 (ESP32-S3 8MB)
└──────────┬───────────────┘
┌────────────────────────────────┐
│ RuvSense multistatic fusion │
│ + cross-viewpoint attention │
│ + AETHER re-ID embeddings │
│ + Cramer-Rao gating │
└────────────────────────────────┘</div>
<h2>Press claim → RuView equivalent</h2>
<div class="card" style="padding: 6px 14px;">
<table>
<thead>
<tr><th>Press claim</th><th>RuView equivalent today</th><th>Crate / ADR</th><th>Honest range</th></tr>
</thead>
<tbody>
<tr>
<td>NV-diamond magnetometry</td>
<td>Deterministic NV pipeline simulator</td>
<td><code>nvsim</code> · ADR-089</td>
<td>Simulator only</td>
</tr>
<tr>
<td>"AI strips environmental noise"</td>
<td>RuvSense multistatic fusion + AETHER</td>
<td>signal/ruvsense/ · ADR-029</td>
<td>Mature</td>
</tr>
<tr>
<td>Heartbeat at distance</td>
<td>60 GHz FMCW HR/BR + WiFi CSI breathing</td>
<td>vitals · ADR-021</td>
<td><span class="pill ok">15 m HR · 1030 m presence</span></td>
</tr>
<tr>
<td>Long-range localisation</td>
<td>Multistatic time-of-flight + CRLB</td>
<td>ruvector/viewpoint/</td>
<td>Limited by node spacing</td>
</tr>
<tr>
<td><i>40-mile single-heartbeat detection</i></td>
<td><i>Not feasible at any tier</i></td>
<td>—</td>
<td><span class="pill skeptical">Press-release physics</span></td>
</tr>
</tbody>
</table>
</div>
<h2>Build today on $165</h2>
<div class="grid">
<div class="card">
<h3>Bill of materials</h3>
<p style="font-family: var(--mono); font-size: 11.5px; line-height: 1.7; color: var(--ink-2);">
3 × ESP32-S3 8 MB ($9 ea)<br>
3 × PoE injector + cat6 ($6 ea)<br>
1 × ESP32-C6 + Seeed MR60BHA2 ($15)<br>
1 × Raspberry Pi 5 8 GB ($80)<br>
1 × unmanaged GbE switch ($25)
</p>
<p><b>Total: $165</b></p>
</div>
<div class="card">
<h3>Honest performance</h3>
<span class="stat"><span class="v">95%</span><span class="l">TPR (LOS, 015 m)</span></span><br><br>
<span class="stat"><span class="v">±2 bpm</span><span class="l">HR (LOS 03 m)</span></span><br><br>
<span class="stat"><span class="v">±1 br/min</span><span class="l">BR (any mode)</span></span><br><br>
<span class="stat"><span class="v">~10 cm</span><span class="l">pose error</span></span><br><br>
<span class="stat"><span class="v">80150 ms</span><span class="l">end-to-end latency</span></span>
</div>
<div class="card">
<h3>Determinism</h3>
<p>Same <code style="font-family: var(--mono); color: var(--accent);">(scene, config, seed)</code> → byte-identical SHA-256 witness across browsers, OSes, transports.</p>
<p>Reference: <span style="font-family: var(--mono); font-size: 10.5px; color: var(--accent-3);">cc8de9b01b0ff5bd…</span></p>
<p>Try the Witness tab on the right — it re-derives the hash live in this browser and compares against the published reference.</p>
</div>
</div>
<h2>Privacy, ethics, legal</h2>
<div class="ethics">
<h3>This is the open-source version. Same physics, opposite governance.</h3>
<ul>
<li><b>Civilian opt-in only</b> — search-and-rescue, elder-care, occupancy, ICU vitals. Not surveillance.</li>
<li><b>No directional pursuit</b> — no beam-steering, target-following, or remote person-of-interest tracking.</li>
<li><b>Data minimisation</b> — fused output is <code>(presence, HR, BR, pose, p_alive)</code>; raw streams discarded at the edge.</li>
<li><b>PII gates</b> (ADR-040) block identifying biometric streams from leaving the local mesh without consent.</li>
<li><b>Adversarial-signal detection</b> flags physically-impossible signal patterns from compromised mesh nodes.</li>
<li><b>No export-controlled hardware</b> — RuView targets &lt; $50 COTS. ITAR/EAR sub-THz coherent radars and shielded NV ensembles are out of scope.</li>
</ul>
<p style="font-size: 11.5px; color: var(--ink-3); margin: 10px 0 0;">
RuView is not affiliated with the United States government, the CIA, Lockheed Martin,
or any classified program. References to "Ghost Murmur" in this view refer
exclusively to the publicly-reported program of that name as covered in the open
press in April 2026.
</p>
</div>
<h2>Cross-references</h2>
<div class="card">
<p style="font-size: 12px; color: var(--ink-2); line-height: 1.7; margin: 0;">
<b>ADRs:</b> 014 (signal) · 021 (vitals) · 024 (AETHER) · 027 (MERIDIAN) ·
028 (witness audit) · 029 (RuvSense) · 040 (PII gates) · 086 (ESP32 RaBitQ) ·
<b>089 (nvsim, Accepted)</b> · 090 (Lindblad, Proposed-conditional) ·
091 (sub-THz radar research) · <b>092 (this dashboard)</b>.<br><br>
<b>Primary physics:</b> Cohen 1970 · Bison 2009 · Wolf 2015 · Barry RMP 2020 · Doherty 2013 · Jackson 3e §5.6/§5.8.
</p>
</div>
`;
}
}
+458
View File
@@ -0,0 +1,458 @@
/* Help center — single dialog covering Quickstart / Glossary / FAQ /
* Shortcuts. Opened from the topbar `?` button or by pressing `?` on
* the keyboard. Self-contained, no external content. */
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
type Section = 'quickstart' | 'glossary' | 'faq' | 'shortcuts' | 'about';
interface GlossaryItem {
term: string;
body: string;
category: 'physics' | 'rust' | 'ui';
}
const GLOSSARY: GlossaryItem[] = [
{ term: 'NV-diamond', category: 'physics', body: 'Nitrogen-vacancy defect in synthetic diamond. The simulator models a 1 mm³ ensemble (~10¹² centers) addressed by 532 nm pump light + a 2.87 GHz microwave drive. Used as a room-temperature magnetometer with shot-noise floor ~1 pT/√Hz at the published lab record.' },
{ term: 'CW-ODMR', category: 'physics', body: 'Continuously-driven optically-detected magnetic resonance. Sweep the microwave frequency around the NV zero-field splitting (D = 2.87 GHz) and watch the photoluminescence dip when the microwave matches the spin transition. The dip splits with applied magnetic field along each of the four ⟨111⟩ NV axes.' },
{ term: 'MagFrame', category: 'rust', body: 'Fixed-layout 60-byte binary record nvsim emits per (sensor × sample). Magic 0xC51A_6E70, version 1, little-endian. Carries timestamp, recovered B vector (pT), per-axis sigma, noise floor, and flag bits for saturation / shot-noise-disabled / heavy-attenuation.' },
{ term: 'Witness', category: 'rust', body: 'SHA-256 hash over the concatenated MagFrame bytes for a canonical reference run (Proof::REFERENCE_SCENE_JSON @ seed=42, N=256). Same inputs → same hash, byte-for-byte, across runs and machines. The dashboard re-derives it in WASM and compares against Proof::EXPECTED_WITNESS_HEX pinned at build time.' },
{ term: 'Determinism gate', category: 'rust', body: 'A pass/fail check: did this build of nvsim produce the expected witness? If yes → every constant (γ_e, D_GS, μ₀, contrast, T₂*, the PRNG stream, the frame layout, the pipeline ordering) is byte-identical to the published reference. If no → something drifted; the dashboard names which.' },
{ term: 'Lock-in demod', category: 'physics', body: 'Multiply the photoluminescence signal by cos(2π·f_mod·t) and low-pass to recover the slowly-varying B-field component. The simulator emulates a lock-in with output gain 2 and a single-pole IIR LP filter; settable via the Tunables panel (f_mod default 1 kHz).' },
{ term: 'Shot-noise floor', category: 'physics', body: 'δB = 1 / (γ_e · C · √(N · t · T₂*)) — the irreducible quantum noise floor for an NV ensemble. With nvsim defaults (N=10¹², C=0.03, T₂*=200 ns): ≈1.18 pT/√Hz. Toggleable via the Tunables panel for "analytic" runs without noise.' },
{ term: 'Biot-Savart', category: 'physics', body: 'Closed-form magnetic field at a point from a current loop or a magnetic dipole. The Scene panel\'s sources (heart proxy, mains loop, ferrous body, eddy current) all reduce to Biot-Savart-style superpositions over the sensor position.' },
{ term: 'Multistatic fusion', category: 'physics', body: 'Combining evidence from multiple sensors at known geometric configurations. RuView\'s Cramer-Rao-weighted attention over WiFi CSI nodes + 60 GHz radar nodes + (hypothetically) NV nodes; documented in ADR-029 and the Ghost Murmur view.' },
{ term: 'Scene', category: 'ui', body: 'The simulated magnetic environment: a list of sources (dipole, current loop, ferrous body, eddy current) plus one or more sensor positions and an ambient field. The dashboard ships a "rebar-walkby-01" reference scene; click "New scene…" in the command palette (⌘K) to build your own.' },
{ term: 'Tunables', category: 'ui', body: 'Sliders that change the running pipeline\'s digitiser config. Each edit debounces 300 ms, then rebuilds the WASM pipeline with the new f_s / f_mod / dt / shot-noise setting. The frame stream picks up the change without a restart.' },
{ term: 'Transport', category: 'ui', body: 'How the dashboard talks to nvsim. Default is WASM — the simulator runs in a Web Worker right here in your browser, no server. The optional WS transport is REST + binary WebSocket against a host-supplied nvsim-server (see ADR-092 §6.2). Toggle in Settings.' },
{ term: 'App Store', category: 'ui', body: 'Catalog of all 65+ hot-loadable WASM edge modules from wifi-densepose-wasm-edge plus the simulators. Each card carries id / category / status / event IDs; the toggle marks an app active in this session and (in WS mode) pushes the activation to a connected ESP32 mesh.' },
{ term: 'Ghost Murmur', category: 'ui', body: 'Research view that audits the publicly-reported April 2026 CIA NV-diamond heartbeat detector against the open physics literature. Includes a live "Try it yourself" sandbox where you can place a heart dipole at any distance from the sensor and ask: which transport tier would actually detect it?' },
];
const FAQ = [
{
q: 'Is this a real simulator or a mockup?',
a: 'Real. The Rust crate at v2/crates/nvsim is the same code that runs in the browser via WASM. Press <b>Verify witness</b> on the Witness panel — the SHA-256 you see is byte-equivalent to what `cargo test -p nvsim` produces.',
},
{
q: 'Why does my "Recovered |B|" sit much higher than "Predicted |B|" in the Ghost Murmur demo?',
a: 'The recovered value reads the simulator\'s ADC quantization floor, not the actual magnetic signal. With COTS-default sensor noise (~300 pT/√Hz) and 16-bit ADC at ±10 µT FS, anything below ~1 pT vanishes into ~2 nT of digitization residual. That\'s the lesson — the press claim sits far below this floor at any meaningful range.',
},
{
q: 'Can I run my own scene?',
a: 'Yes. Press ⌘K to open the command palette and pick "New scene…". You get five fields (name, dipole moment, distance, ferrous toggle, mains toggle); the dashboard builds the JSON and pushes it via <code>client.loadScene()</code>.',
},
{
q: 'Does any of my data leave the browser?',
a: 'No. WASM mode is local-only — the worker, the WASM binary, and the IndexedDB persistence all live in your browser. The optional WS transport (off by default) talks to a host of your choosing.',
},
{
q: 'What does the witness mismatch (red ✗) mean?',
a: 'The current build of nvsim produced a SHA-256 that doesn\'t match the constant pinned at compile time. Possible causes: a different Rust toolchain, a dependency version drift, a manual edit to a physics constant, or an honest bug. Audit the diff against ADR-089 §5.',
},
{
q: 'Why are the Inspector / Witness rail buttons there if there\'s already a right-side inspector?',
a: 'The right-side inspector is the compact live view; the rail buttons open a full-width version with bigger charts, an explainer header, reference-scene metadata cards, and (on Witness) a "what this verifies" panel. Both stay in sync — the right rail is for glancing, the main area is for diving in.',
},
{
q: 'Why is there an "App Store" if this is a magnetometer simulator?',
a: 'Because nvsim is one tile in a larger sensing platform. The catalog lists every hot-loadable WASM edge module RuView ships — medical, security, building, retail, industrial, signal, learning, autonomy. The simulators (nvsim today, more in future) are first-class entries in the same catalog.',
},
];
const QUICKSTART = [
{ step: 1, title: 'Hit ▶ Run', body: 'The big amber button in the topbar starts the live frame stream. The pipeline runs ~1.8 kHz on x86_64 WASM, well above the 1 kHz Cortex-A53 acceptance gate.' },
{ step: 2, title: 'Watch the B-vector trace', body: 'The Inspector → Signal tab shows the recovered field per axis updating in real time. The frame strip below it is one bar per ~32-frame batch.' },
{ step: 3, title: 'Verify the witness', body: 'Click the rail Witness button (or REPL: <code>proof.verify</code>). The dashboard re-runs the canonical reference scene and asserts the SHA-256 byte-for-byte.' },
{ step: 4, title: 'Drag a source', body: 'Grab the rebar / heart proxy / mains loop / ferrous door in the scene canvas; positions persist via IndexedDB.' },
{ step: 5, title: 'Tweak the tunables', body: 'Sliders in the left sidebar update the running pipeline (f_s, f_mod, integration time, shot-noise). Changes debounce 300 ms then push to the worker.' },
{ step: 6, title: 'Open the Ghost Murmur view', body: 'The ghost icon in the rail. Move the distance + moment sliders, hit "Run nvsim at this distance" — the live demo runs the real Rust pipeline through WASM and shows which transport tier would actually detect.' },
{ step: 7, title: 'Browse the App Store', body: 'The grid icon. 65+ edge apps: medical, security, building, retail, industrial, signal, learning. Toggle to mark active in this session.' },
];
const SHORTCUTS = [
{ keys: '⌘K / Ctrl K', label: 'Command palette' },
{ keys: 'Space', label: 'Play / pause pipeline' },
{ keys: '⌘R / Ctrl R', label: 'Reset pipeline (with confirm)' },
{ keys: '⌘, / Ctrl ,', label: 'Settings drawer' },
{ keys: '⌘N / Ctrl N', label: 'New scene' },
{ keys: '⌘E / Ctrl E', label: 'Export proof bundle' },
{ keys: '⌘/ / Ctrl /', label: 'Toggle theme (dark / light)' },
{ keys: '`', label: 'Toggle debug HUD' },
{ keys: '?', label: 'Open this help center' },
{ keys: '1 · 2 · 3', label: 'Switch inspector tab (Signal / Frame / Witness)' },
{ keys: 'Esc', label: 'Close any modal / palette / drawer' },
{ keys: '/', label: 'Focus the REPL prompt' },
];
@customElement('nv-help')
export class NvHelp extends LitElement {
@state() private open = false;
@state() private section: Section = 'quickstart';
@state() private query = '';
static styles = css`
:host {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
z-index: 230;
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(880px, 94vw);
max-height: 86vh;
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: auto 1fr auto;
overflow: hidden;
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); }
@media (max-width: 700px) {
.modal { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr auto; max-height: 92vh; }
.nav { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; overflow-x: auto; }
.nav button { white-space: nowrap; }
}
.h {
grid-column: 1 / -1;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between;
}
.h .ttl { font-size: 15px; font-weight: 600; }
.nav {
border-right: 1px solid var(--line);
padding: 12px 8px;
display: flex; flex-direction: column; gap: 2px;
background: var(--bg-1);
}
.nav button {
text-align: left;
padding: 8px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--ink-3);
font-size: 12.5px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.nav button:hover { color: var(--ink); background: var(--bg-2); }
.nav button.on {
color: var(--ink); background: var(--bg-3);
border-color: var(--line-2);
}
.body {
padding: 18px 22px;
overflow-y: auto;
font-size: 13px;
color: var(--ink-2);
line-height: 1.6;
}
.body h2 {
margin: 0 0 8px;
font-size: 18px;
color: var(--ink);
letter-spacing: -0.01em;
}
.body .lead {
color: var(--ink-3);
font-size: 12.5px;
margin: 0 0 14px;
}
.body p { margin: 0 0 12px; }
.body code {
font-family: var(--mono);
background: var(--bg-3);
padding: 1px 5px;
border-radius: 4px;
font-size: 11.5px;
color: var(--accent);
}
.body kbd {
font-family: var(--mono);
padding: 2px 6px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 4px;
font-size: 11.5px;
color: var(--ink);
}
.step {
display: grid;
grid-template-columns: 32px 1fr;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.step:last-child { border-bottom: 0; }
.step .num {
width: 26px; height: 26px;
border-radius: 50%;
background: var(--accent);
color: #1a0f00;
font-family: var(--mono);
font-size: 12.5px;
font-weight: 700;
display: grid; place-items: center;
}
.step .ttl { color: var(--ink); font-weight: 600; font-size: 13.5px; margin-bottom: 2px; }
.step .body-text { font-size: 12.5px; color: var(--ink-2); line-height: 1.55; }
.glossary-search {
width: 100%;
padding: 8px 12px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 6px;
font-family: var(--mono);
font-size: 12.5px;
color: var(--ink);
outline: none;
margin-bottom: 14px;
}
.glossary-search:focus { border-color: var(--accent); }
.term {
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.term:last-child { border-bottom: 0; }
.term .head {
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
}
.term .name {
font-family: var(--mono);
font-size: 13.5px;
color: var(--accent);
font-weight: 600;
}
.term .badge {
font-family: var(--mono);
font-size: 9.5px;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--line);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.term .badge.physics { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.4); }
.term .badge.rust { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.4); }
.term .badge.ui { color: var(--accent-4); border-color: oklch(0.78 0.14 145 / 0.4); }
.term .body-text {
font-size: 12.5px;
color: var(--ink-2);
line-height: 1.55;
}
.faq-item {
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.faq-item:last-child { border-bottom: 0; }
.faq-item .q {
color: var(--ink);
font-weight: 600;
font-size: 13.5px;
margin-bottom: 4px;
}
.faq-item .a { font-size: 12.5px; color: var(--ink-2); line-height: 1.55; }
.shortcuts {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 16px;
align-items: baseline;
}
.f {
grid-column: 1 / -1;
padding: 10px 18px;
border-top: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between;
font-size: 11.5px; color: var(--ink-3);
}
.close {
width: 28px; height: 28px;
background: transparent; border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink-2);
cursor: pointer;
}
.close:hover { color: var(--ink); border-color: var(--line-2); }
`;
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener('nv-show-help', this.show as EventListener);
window.addEventListener('nv-show-help-close', this.closeListener);
window.addEventListener('keydown', this.onKey);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('nv-show-help', this.show as EventListener);
window.removeEventListener('nv-show-help-close', this.closeListener);
window.removeEventListener('keydown', this.onKey);
}
private closeListener = (): void => this.close();
private show = (e: Event): void => {
const detail = (e as CustomEvent).detail as { section?: Section } | undefined;
if (detail?.section) this.section = detail.section;
this.open = true;
this.setAttribute('open', '');
};
private close(): void {
this.open = false;
this.removeAttribute('open');
}
private onKey = (e: KeyboardEvent): void => {
const target = e.target as HTMLElement | null;
const isInput = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
if (e.key === '?' && !isInput && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.show(new CustomEvent('nv-show-help'));
} else if (e.key === 'Escape' && this.open) {
this.close();
}
};
private filteredGlossary(): GlossaryItem[] {
if (!this.query.trim()) return GLOSSARY;
const q = this.query.toLowerCase();
return GLOSSARY.filter((g) =>
g.term.toLowerCase().includes(q) || g.body.toLowerCase().includes(q),
);
}
private renderQuickstart() {
return html`
<h2>Quickstart</h2>
<p class="lead">Seven taps to get from "I just opened the dashboard" to "I'm running my own scene with verified determinism."</p>
<button
style="display:inline-flex; align-items:center; gap:8px; padding:10px 16px; margin-bottom:14px; background:var(--accent); color:#1a0f00; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; font-family:inherit;"
@click=${() => { window.dispatchEvent(new CustomEvent('nv-show-help-close')); window.dispatchEvent(new CustomEvent('nv-show-tour')); }}>
★ Take the interactive 10-step tour
</button>
${QUICKSTART.map((s) => html`
<div class="step">
<div class="num">${s.step}</div>
<div>
<div class="ttl">${s.title}</div>
<div class="body-text" .innerHTML=${s.body}></div>
</div>
</div>
`)}
`;
}
private renderGlossary() {
const items = this.filteredGlossary();
return html`
<h2>Glossary</h2>
<p class="lead">Every piece of jargon in the dashboard, defined in one paragraph each.</p>
<input class="glossary-search" type="text" placeholder="Search 14 terms…"
.value=${this.query}
@input=${(e: Event) => this.query = (e.target as HTMLInputElement).value} />
${items.length === 0
? html`<p style="color: var(--ink-3);">No terms match.</p>`
: items.map((g) => html`
<div class="term">
<div class="head">
<span class="name">${g.term}</span>
<span class="badge ${g.category}">${g.category}</span>
</div>
<div class="body-text">${g.body}</div>
</div>
`)}
`;
}
private renderFaq() {
return html`
<h2>FAQ</h2>
<p class="lead">The questions I was asked twice in the first week of demos.</p>
${FAQ.map((item) => html`
<div class="faq-item">
<div class="q">${item.q}</div>
<div class="a" .innerHTML=${item.a}></div>
</div>
`)}
`;
}
private renderShortcuts() {
return html`
<h2>Keyboard shortcuts</h2>
<p class="lead">Everything is reachable without a mouse.</p>
<div class="shortcuts">
${SHORTCUTS.map((s) => html`
<kbd>${s.keys}</kbd><span>${s.label}</span>
`)}
</div>
`;
}
private renderAbout() {
return html`
<h2>About this dashboard</h2>
<p class="lead">What you're looking at, in one screen.</p>
<p><b>nvsim</b> is a deterministic forward simulator for nitrogen-vacancy diamond magnetometry.
The Rust crate at <code>v2/crates/nvsim</code> is the source of truth; this dashboard is a
Vite + Lit single-page app that ships the crate compiled to WebAssembly inside a Web Worker.</p>
<p>The defining commitment is <b>determinism</b>: same <code>(scene, config, seed)</code> →
byte-identical SHA-256 witness across browsers, OSes, and transports. Press the
<kbd>Verify witness</kbd> button on the Witness tab to assert this live.</p>
<p>The codebase is open source (Apache-2.0 OR MIT). Find it on GitHub:
<code>github.com/ruvnet/RuView</code>. Decisions are documented in ADRs 089 (nvsim),
090 (Lindblad extension, conditional), 091 (sub-THz radar research),
092 (this dashboard), 093 (UX gap analysis).</p>
<p>This dashboard is one of several RuView demos. Sibling demos at
<code>github.io/RuView/</code> include the Observatory and Pose Fusion views.</p>
`;
}
override render() {
return html`
<div class="modal" role="dialog" aria-modal="true" aria-label="Help center">
<div class="h">
<div class="ttl">Help</div>
<button class="close" aria-label="Close help" @click=${() => this.close()}>×</button>
</div>
<nav class="nav" role="tablist" aria-label="Help sections">
${(['quickstart', 'glossary', 'faq', 'shortcuts', 'about'] as Section[]).map((s) => html`
<button class=${this.section === s ? 'on' : ''} role="tab"
aria-selected=${this.section === s}
@click=${() => this.section = s}>
${s === 'quickstart' ? '🚀 Quickstart'
: s === 'glossary' ? '📖 Glossary'
: s === 'faq' ? '? FAQ'
: s === 'shortcuts' ? '⌨ Shortcuts'
: ' About'}
</button>
`)}
</nav>
<div class="body" role="tabpanel">
${this.section === 'quickstart' ? this.renderQuickstart()
: this.section === 'glossary' ? this.renderGlossary()
: this.section === 'faq' ? this.renderFaq()
: this.section === 'shortcuts' ? this.renderShortcuts()
: this.renderAbout()}
</div>
<div class="f">
<span>Press <kbd style="font-family:var(--mono);font-size:10.5px;padding:1px 4px;background:var(--bg-3);border:1px solid var(--line);border-radius:3px;">?</kbd> any time to reopen</span>
<span>nvsim · Apache-2.0 OR MIT</span>
</div>
</div>
`;
}
}
export function showHelp(section?: Section): void {
window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section } }));
}
+270
View File
@@ -0,0 +1,270 @@
/* Home view — friendly landing surface for new users.
*
* The full-power scene + sidebar + inspector + console are intentionally
* dense; that's the operator surface. Home is for first-time visitors:
* a single hero CTA, four quick-jump action cards, and a 1-paragraph
* explanation of what this dashboard is. No jargon above the fold.
*/
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { effect } from '@preact/signals-core';
import { running, getClient, witnessVerified, fps, pushLog } from '../store/appStore';
export type Action = 'scene' | 'apps' | 'witness' | 'ghost-murmur' | 'help' | 'tour';
@customElement('nv-home')
export class NvHome extends LitElement {
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: 28px clamp(16px, 6vw, 56px) 60px;
}
.hero {
max-width: 800px;
margin: 16px auto 28px;
text-align: center;
}
.hero .icon {
width: 56px; height: 56px;
margin: 0 auto 18px;
border-radius: 14px;
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
display: grid; place-items: center;
font-family: var(--mono);
font-weight: 700;
font-size: 18px;
color: #1a0f00;
box-shadow: 0 8px 24px -6px oklch(0.55 0.16 30 / 0.4);
}
.hero h1 {
margin: 0 0 8px;
font-size: clamp(24px, 4vw, 34px);
letter-spacing: -0.02em;
color: var(--ink);
line-height: 1.15;
}
.hero .tag {
font-size: clamp(13px, 1.6vw, 15px);
color: var(--ink-2);
margin: 0 0 22px;
line-height: 1.55;
}
.hero .ctas {
display: flex; flex-wrap: wrap; gap: 8px;
justify-content: center;
}
.cta {
padding: 11px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--ink);
transition: transform 0.12s, border-color 0.12s, filter 0.12s;
}
.cta:hover { transform: translateY(-1px); border-color: var(--line-2); }
.cta.primary {
background: var(--accent);
border-color: var(--accent);
color: #1a0f00;
}
.cta.primary:hover { filter: brightness(1.08); }
.status {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-family: var(--mono);
color: var(--ink-2);
margin-top: 18px;
}
.status .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--ink-3);
}
.status.live .dot {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
animation: pulse 2s infinite;
}
@keyframes pulse { 50% { opacity: 0.5; } }
.grid {
max-width: 980px;
margin: 36px auto 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.card {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px 20px;
cursor: pointer;
transition: transform 0.12s, border-color 0.12s, background 0.12s;
display: flex; flex-direction: column; gap: 6px;
text-align: left;
color: inherit;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--accent);
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
}
.card .ico {
font-size: 22px;
line-height: 1;
margin-bottom: 4px;
}
.card h3 {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
}
.card p {
margin: 0;
font-size: 12.5px;
color: var(--ink-2);
line-height: 1.55;
}
.card .arrow {
color: var(--accent);
font-family: var(--mono);
font-size: 11.5px;
margin-top: 6px;
}
.footnote {
max-width: 800px;
margin: 36px auto 0;
text-align: center;
font-size: 12px;
color: var(--ink-3);
line-height: 1.55;
}
.footnote code {
font-family: var(--mono);
background: var(--bg-3);
padding: 1px 5px;
border-radius: 4px;
color: var(--accent);
font-size: 11px;
}
.footnote a {
color: var(--accent-2);
text-decoration: underline dotted;
cursor: pointer;
}
`;
override connectedCallback(): void {
super.connectedCallback();
effect(() => { running.value; witnessVerified.value; fps.value; this.requestUpdate(); });
}
private go(action: Action): void {
if (action === 'tour') { window.dispatchEvent(new CustomEvent('nv-show-tour')); return; }
if (action === 'help') { window.dispatchEvent(new CustomEvent('nv-show-help')); return; }
this.dispatchEvent(new CustomEvent('navigate', { detail: action, bubbles: true, composed: true }));
}
private async runDemo(): Promise<void> {
const c = getClient(); if (!c) return;
if (running.value) return;
await c.run();
running.value = true;
pushLog('ok', 'demo started · streaming MagFrames');
}
override render() {
const isRunning = running.value;
const wasVerified = witnessVerified.value === 'ok';
return html`
<div class="hero">
<div class="icon" aria-hidden="true">NV</div>
<h1>An open-source quantum-magnetometer simulator, in your browser.</h1>
<p class="tag">
nvsim runs a real Rust simulator (the same code that
<code style="font-family:var(--mono); background:var(--bg-3); padding:1px 5px; border-radius:4px; color:var(--accent); font-size:12px;">cargo&nbsp;test</code>
uses) entirely in WebAssembly. No server, no upload, no telemetry.
Press the button to start the live magnetic-field simulation, or
take the 60-second tour first.
</p>
<div class="ctas">
<button class="cta primary" id="home-run-btn" @click=${() => this.runDemo()}>
${isRunning ? '✓ Demo running' : '▶ Run the simulation'}
</button>
<button class="cta" id="home-tour-btn" @click=${() => this.go('tour')}>
★ Take the 60-second tour
</button>
<button class="cta" id="home-help-btn" @click=${() => this.go('help')}>
? Help center
</button>
</div>
<div class="status ${isRunning ? 'live' : ''}">
<span class="dot"></span>
${isRunning
? html`Live · ${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'starting…'}${wasVerified ? ' · witness verified ✓' : ''}`
: html`Idle${wasVerified ? ' · witness verified ✓' : ''}`}
</div>
</div>
<div class="grid">
<div class="card" tabindex="0" role="button"
@click=${() => this.go('scene')}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('scene'); } }}>
<div class="ico">🌐</div>
<h3>Live scene</h3>
<p>Drag magnetic sources, watch the recovered field update in real time, and tweak sample rate / noise / integration.</p>
<div class="arrow">Open scene →</div>
</div>
<div class="card" tabindex="0" role="button"
@click=${() => this.go('apps')}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('apps'); } }}>
<div class="ico">🛍</div>
<h3>App Store · 66 edge apps</h3>
<p>Browse 65 hot-loadable WASM sensing modules across medical, security, building, retail, industrial, learning. Six run live in the browser.</p>
<div class="arrow">Browse the catalogue →</div>
</div>
<div class="card" tabindex="0" role="button"
@click=${() => this.go('witness')}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('witness'); } }}>
<div class="ico">✓</div>
<h3>Determinism gate</h3>
<p>Re-derive the SHA-256 witness for the canonical reference scene right here in your browser. Same inputs → same hash, every time.</p>
<div class="arrow">Verify the witness →</div>
</div>
<div class="card" tabindex="0" role="button"
@click=${() => this.go('ghost-murmur')}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('ghost-murmur'); } }}>
<div class="ico">👻</div>
<h3>Ghost Murmur reality check</h3>
<p>Audit the publicly-reported April 2026 CIA NV-diamond program against published physics. Live distance/moment sliders.</p>
<div class="arrow">Read the spec →</div>
</div>
</div>
<p class="footnote">
New here? <a @click=${() => this.go('tour')}>Take the 60-second guided tour</a>
— every panel is explained. Or press <code>?</code> for the help center
(quickstart, glossary, FAQ, shortcuts) any time.<br>
Open source · Apache-2.0 OR MIT · <code>github.com/ruvnet/RuView</code>
</p>
`;
}
}
+434
View File
@@ -0,0 +1,434 @@
/* Inspector — tabbed: Signal / Frame / Witness. */
import { LitElement, html, css, svg, type PropertyValues } from 'lit';
import { customElement, state, property } 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';
/** When set by the parent, force the tab and pulse-highlight it. */
@property({ attribute: false }) pinTab: Tab | null = null;
/** When `expanded`, the inspector renders as a full-screen view with bigger
* charts and a wider Witness panel. Used when the rail Inspector/Witness
* button is clicked — see ADR-093 P1.13. */
@property({ type: Boolean, reflect: true }) expanded = false;
static styles = css`
:host {
display: flex; flex-direction: column;
background: var(--bg-1);
border-left: 1px solid var(--line);
overflow: hidden;
height: 100%;
}
:host([expanded]) {
border-left: 0;
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
}
:host([expanded]) .tabs {
padding: 0 24px;
background: var(--bg-1);
}
:host([expanded]) .tab {
padding: 16px 22px;
font-size: 13.5px;
flex: 0 0 auto;
}
:host([expanded]) .body {
padding: 24px 28px;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
:host([expanded]) .card { padding: 18px 20px; }
:host([expanded]) .card-h .ttl { font-size: 14px; }
:host([expanded]) svg { height: 220px; }
:host([expanded]) .frame-strip { height: 48px; }
:host([expanded]) table { font-size: 12.5px; }
:host([expanded]) td { padding: 6px 0; }
:host([expanded]) .hex { font-size: 12px; padding: 14px; line-height: 1.7; }
:host([expanded]) .witness-box { font-size: 13px; padding: 14px 16px; line-height: 1.6; }
:host([expanded]) .verify-btn { padding: 12px; font-size: 13px; }
:host([expanded]) .grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
:host([expanded]) .grid-2 > .card { margin-bottom: 0; }
@media (max-width: 1024px) {
:host([expanded]) .grid-2 { grid-template-columns: 1fr; }
}
.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();
});
}
override willUpdate(changed: PropertyValues): void {
// Apply parent-driven tab pin during willUpdate so the new tab value
// participates in this same render pass — avoids the "update after
// update completed" Lit warning that would fire if we did this in
// updated().
if (changed.has('pinTab') && this.pinTab && this.tab !== this.pinTab) {
this.tab = this.pinTab;
}
}
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 renderHeader() {
if (!this.expanded) return '';
const titles: Record<Tab, string> = {
signal: 'Signal inspector — live B-vector trace + frame stream',
frame: 'Frame inspector — MagFrame v1 fields + raw bytes',
witness: 'Witness panel — SHA-256 determinism gate',
};
return html`
<h1 style="margin: 8px 0 14px; font-size: 20px; letter-spacing: -0.01em;">
${titles[this.tab]}
</h1>
<p style="margin: 0 0 18px; font-size: 12.5px; color: var(--ink-3); line-height: 1.55; max-width: 780px;">
${this.tab === 'signal'
? 'Real-time recovered field-vector and frame-stream sparkline. Both update at the running pipeline\'s frame rate. Use the Tunables panel in the sidebar to change f_s, f_mod, dt, and shot-noise behaviour.'
: this.tab === 'frame'
? 'Decoded view of the most recent MagFrame: typed fields plus the raw 60-byte little-endian binary record (magic 0xC51A_6E70).'
: 'Re-derive the SHA-256 witness for the canonical reference scene (seed=42, N=256) right now in your browser and compare against Proof::EXPECTED_WITNESS_HEX. Same inputs → same hash, byte-for-byte, across every machine and transport.'}
</p>
`;
}
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;
};
const b = lastB.value;
const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
const hasData = traceX.value.length > 0;
return html`
${!hasData ? html`
<div class="card" style="text-align:center; padding:18px;">
<div style="font-size:13px; color:var(--ink-2); line-height:1.55;">
No frames yet. Press <b>▶ Run</b> in the topbar (or hit <code style="font-family:var(--mono);background:var(--bg-3);padding:1px 5px;border-radius:4px;color:var(--accent);">Space</code>)
to start the live B-vector trace.
</div>
</div>
` : ''}
<div class=${this.expanded ? 'grid-2' : ''}>
<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>
${this.expanded ? html`<div style="display:flex;gap:14px;font-size:12px;font-family:var(--mono);margin-top:8px;">
<span style="color:oklch(0.78 0.14 70);">x: ${bnT[0].toFixed(3)} nT</span>
<span style="color:oklch(0.78 0.12 195);">y: ${bnT[1].toFixed(3)} nT</span>
<span style="color:oklch(0.72 0.18 330);">z: ${bnT[2].toFixed(3)} nT</span>
<span style="color:var(--accent);margin-left:auto;">|B| ${(bMag.value * 1e9).toFixed(3)} nT</span>
</div>` : ''}
</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>
${this.expanded ? html`
<div style="display:flex;gap:24px;font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-top:12px;">
<span>frames in window: <span style="color:var(--ink);">${stripBars.value.length}</span></span>
<span>noise floor: <span style="color:var(--ink);">${lastFrame.value ? lastFrame.value.noiseFloorPtSqrtHz.toFixed(2) + ' pT/√Hz' : '—'}</span></span>
</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`
${!f ? html`
<div class="card" style="text-align:center; padding:18px;">
<div style="font-size:13px; color:var(--ink-2); line-height:1.55;">
No MagFrame to display yet. Start the pipeline (<b>▶ Run</b>) to populate.
</div>
</div>
` : ''}
<div class=${this.expanded ? 'grid-2' : ''}>
<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>
${this.expanded ? html`
<div style="font-size: 11.5px; color: var(--ink-3); margin-top: 10px; line-height: 1.6;">
Layout (little-endian): <code>magic(u32) version(u16) flags(u16) sensor_id(u16) _reserved(u16) t_us(u64) b_pt[3](f32) sigma_pt[3](f32) noise_floor(f32) temp_K(f32)</code>.
</div>` : ''}
</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';
const match = expectedWitness.value && witnessHex.value && expectedWitness.value === witnessHex.value;
return html`
${this.expanded ? html`
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:12px;margin-bottom:18px;">
<div class="card" style="margin:0;">
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Reference scene</div>
<div style="font-family:var(--mono);font-size:14px;color:var(--ink);margin-top:4px;">Proof::REFERENCE</div>
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">2 dipoles · 1 loop · 1 ferrous · 1 sensor</div>
</div>
<div class="card" style="margin:0;">
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Seed</div>
<div style="font-family:var(--mono);font-size:14px;color:var(--accent);margin-top:4px;">0x0000002A</div>
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">canonical Proof::SEED</div>
</div>
<div class="card" style="margin:0;">
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Sample count</div>
<div style="font-family:var(--mono);font-size:14px;color:var(--ink);margin-top:4px;">256</div>
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">Proof::N_SAMPLES</div>
</div>
<div class="card" style="margin:0;">
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Status</div>
<div style="font-family:var(--mono);font-size:14px;margin-top:4px;color:${status === 'ok' ? 'var(--ok)' : status === 'fail' ? 'var(--bad)' : 'var(--ink-3)'};">
${status === 'ok' ? '✓ matches' : status === 'fail' ? '✗ drift' : status === 'pending' ? '… running' : '— idle'}
</div>
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">${match ? 'byte-equivalent' : 'not yet verified'}</div>
</div>
</div>
` : ''}
<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>
${this.expanded ? html`
<div class="card">
<div class="card-h">
<span class="ttl">What this verifies</span>
<span class="badge">ADR-089 §5</span>
</div>
<div style="font-size: 12.5px; color: var(--ink-2); line-height: 1.6;">
<p style="margin: 0 0 10px;">Pressing <b>Verify</b> runs the canonical reference pipeline
(<code>Proof::generate</code>) end-to-end inside this browser's WASM Worker:
scene → Biot-Savart synthesis → material attenuation → NV ensemble → ADC + lock-in →
concatenated <code>MagFrame</code> bytes → SHA-256.</p>
<p style="margin: 0 0 10px;">If the resulting hash matches the constant pinned at build time
(<code>cc8de9b01b0ff5bd…</code>), every constant — γ_e, D_GS, μ₀, T₂*, contrast, the PRNG
stream, the frame layout, the pipeline ordering — is byte-identical to the published
reference. If it doesn't match, <i>something</i> drifted; the dashboard names which.</p>
<p style="margin: 0;">This is the same regression test that runs in
<code>cargo test -p nvsim</code> — running in your browser, against your own WASM build.</p>
</div>
</div>
` : ''}
`;
}
override render() {
return html`
<div class="tabs" role="tablist">
<button class="tab ${this.tab === 'signal' ? 'active' : ''}" data-pane="signal"
role="tab" aria-selected=${this.tab === 'signal'}
@click=${() => this.tab = 'signal'}>Signal</button>
<button class="tab ${this.tab === 'frame' ? 'active' : ''}" data-pane="frame"
role="tab" aria-selected=${this.tab === 'frame'}
@click=${() => this.tab = 'frame'}>Frame</button>
<button class="tab ${this.tab === 'witness' ? 'active' : ''}" data-pane="witness"
role="tab" aria-selected=${this.tab === 'witness'}
@click=${() => this.tab = 'witness'}>Witness</button>
</div>
<div class="body" role="tabpanel">
${this.renderHeader()}
${this.tab === 'signal' ? this.renderSignalTab()
: this.tab === 'frame' ? this.renderFrameTab()
: this.renderWitnessTab()}
</div>
`;
}
}
+153
View File
@@ -0,0 +1,153 @@
/* 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', '');
// a11y: focus the first interactive element inside the modal so keyboard
// users land in the dialog rather than behind it. Light focus trap via
// the keydown handler below catches Tab cycling.
requestAnimationFrame(() => {
const root = this.shadowRoot;
if (!root) return;
const first = root.querySelector<HTMLElement>('input, select, textarea, button:not(.close)');
first?.focus();
});
};
override updated(): void {
if (!this.open) return;
const root = this.shadowRoot;
if (!root) return;
// Trap Tab inside the modal while open.
const trap = (e: KeyboardEvent): void => {
if (e.key !== 'Tab') return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>('input, select, textarea, button, [href]'),
).filter((el) => !el.hasAttribute('disabled'));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = (root.activeElement as HTMLElement | null) ?? null;
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
};
root.removeEventListener('keydown', trap as EventListener);
root.addEventListener('keydown', trap as EventListener);
}
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 }));
}
+397
View File
@@ -0,0 +1,397 @@
/* Welcome modal + step-by-step introduction tour.
*
* 10 steps walking the user through every panel of the dashboard with
* concrete CTAs ("Try it now") that fire real navigation against the
* live UI. First-run only by default; replayable via Settings → Help.
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { kvGet, kvSet } from '../store/persistence';
interface TourStep {
/** Optional icon shown at the top of the step. */
icon: string;
title: string;
/** Markdown-ish HTML body (rendered via .innerHTML). */
body: string;
/** Optional CTA: clicking runs the action then advances. */
cta?: { label: string; run?: () => void };
/** Optional "do this yourself" hint. */
hint?: string;
}
const STEPS: TourStep[] = [
{
icon: '👋',
title: 'Welcome to nvsim',
body: `<p style="font-size:14px; line-height:1.6;">
<b>nvsim</b> is an open-source, deterministic forward simulator for
<b>nitrogen-vacancy diamond magnetometry</b> — a real Rust crate compiled
to WebAssembly and running in your browser, right now.</p>
<p style="font-size:13px; color:var(--ink-2); line-height:1.55;">
This 60-second tour walks you through the four panels, the App Store,
the Ghost Murmur research view, and the determinism contract that
makes nvsim distinctive.</p>
<p style="font-size:11.5px; color:var(--ink-3); line-height:1.5; margin-top:14px;">
Press <kbd>Esc</kbd> any time to skip. You can replay this tour from
<b>Settings → Help</b>.</p>`,
cta: { label: 'Start the tour →' },
},
{
icon: '🌐',
title: 'The Scene canvas',
body: `<p>The middle panel shows your <b>magnetic scene</b> — a small simulated
environment with four sources and one NV-diamond sensor at the centre.</p>
<p>The four amber/cyan/magenta blobs are draggable: <b>rebar coil</b>
(steel χ=5000), <b>heart proxy</b> dipole, <b>60 Hz mains</b> current loop,
and a <b>steel door</b> (eddy current). Field lines connect each source
to the sensor and animate while the pipeline runs.</p>
<p style="font-size:12.5px; color:var(--ink-3);">
Top-left toolbar: zoom in/out, fit-to-view, layer toggles. Bottom-right:
sim controls (step / play / step / speed cycle). Drag positions persist
across reloads.</p>`,
hint: 'Try dragging the heart_proxy after the tour ends.',
},
{
icon: '▶',
title: 'Run the pipeline',
body: `<p>Press <b>▶ Run</b> in the topbar (or hit <kbd>Space</kbd>) to start
the live frame stream. nvsim runs at ~1.8 kHz on x86_64 WASM —
well above the 1 kHz Cortex-A53 acceptance gate.</p>
<p>The FPS pill in the topbar updates with the throughput. The B-vector
trace and frame-stream sparkline in the right inspector update in real
time.</p>
<p style="font-size:12.5px; color:var(--ink-3);">
<kbd>Space</kbd> toggles run/pause from anywhere. Reset (<kbd>⌘R</kbd>)
rewinds <code>t</code> to 0 without changing the seed.</p>`,
},
{
icon: '🔍',
title: 'Inspector — three tabs, three depths',
body: `<p>The right rail shows the live inspector: <b>Signal</b> (B-vector
trace + frame-stream sparkline), <b>Frame</b> (decoded MagFrame fields +
raw 60-byte hex dump), <b>Witness</b> (SHA-256 determinism gate).</p>
<p>Click the <b>magnifier</b> icon in the left rail to expand the
inspector to the full main area, with bigger charts and an explainer
header. Click the <b>shield</b> icon to do the same focused on Witness.</p>
<p style="font-size:12.5px; color:var(--ink-3);">
Number keys <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> jump between the
three inspector tabs from anywhere.</p>`,
},
{
icon: '✓',
title: 'The witness — what makes nvsim distinctive',
body: `<p>nvsim's defining commitment: same <code>(scene, config, seed)</code> →
byte-identical SHA-256 across runs, machines, and transports.</p>
<p>Click the <b>Witness</b> tab and press <b>Verify witness</b>. The
dashboard re-derives the hash for the canonical reference scene
(<code>seed=42, N=256</code>) and asserts it matches the constant
pinned at compile time
(<code style="font-size:10.5px;">cc8de9b01b0ff5bd…</code>).</p>
<p>A green check means every constant — γ_e, D_GS, μ₀, T₂*, contrast,
the PRNG stream, the frame layout — is byte-identical to the published
reference. A red ✗ means something drifted; the dashboard names which.</p>`,
},
{
icon: '🎚',
title: 'Tunables — change the simulation live',
body: `<p>The left sidebar's <b>Tunables</b> panel has four sliders:</p>
<ul style="margin:0 0 12px; padding-left:18px; font-size:13px; color:var(--ink-2); line-height:1.6;">
<li><b>Sample rate</b> (1100 kHz) — digitiser frame rate</li>
<li><b>Lock-in f_mod</b> (0.15 kHz) — microwave modulation freq</li>
<li><b>Integration t</b> (0.110 ms) — per-sample integration time</li>
<li><b>Shot noise</b> (on/off) — toggle quantum noise</li>
</ul>
<p>Edits debounce 300 ms then rebuild the WASM pipeline without restarting
the frame stream. Watch the noise floor and B-vector spread change
in the Signal trace.</p>`,
},
{
icon: '👻',
title: 'Ghost Murmur — research view',
body: `<p>Click the ghost icon in the left rail. This view audits the
publicly-reported <b>April 2026 CIA Ghost Murmur</b> NV-diamond
heartbeat-detection program against the open physics literature.</p>
<p>Includes a <b>"Try it yourself"</b> sandbox: place a cardiac dipole at
any distance from the sensor, hit Run, and see what the real nvsim
pipeline recovers. Per-tier detectability bars compare the predicted
signal vs each transport's noise floor (NV-ensemble lab, COTS DNV-B1,
SQUID, 60 GHz mmWave, WiFi CSI).</p>
<p style="font-size:12.5px; color:var(--ink-3);">
Spoiler: at 1 km the cardiac MCG is ~10⁻¹² of its 10 cm value.
Press claims of 40-mile detection sit far below any published instrument's
floor.</p>`,
},
{
icon: '🛍',
title: 'App Store — 65 edge apps',
body: `<p>Click the grid icon. The <b>App Store</b> catalogues every
hot-loadable WASM edge module RuView ships, organised by category:
medical, security, smart-building, retail, industrial, signal,
learning, autonomy, exotic.</p>
<p>Each card carries id / category / status / event IDs / compute budget /
ADR back-reference. The toggle marks an app active in this session;
the WS transport (when configured) pushes the activation set to a
connected ESP32 mesh.</p>
<p style="font-size:12.5px; color:var(--ink-3);">
Try searching for "ghost", "heart", or "occupancy" to fuzzy-filter
the catalogue.</p>`,
},
{
icon: '⌨',
title: 'Console + REPL',
body: `<p>The bottom panel is a structured event log with five filter tabs
(<b>all / info / warn / err / dbg</b>) plus a REPL prompt.</p>
<p>REPL commands include
<code>help</code>, <code>scene.list</code>, <code>sensor.config</code>,
<code>run</code>, <code>pause</code>, <code>seed [hex]</code>,
<code>proof.verify</code>, <code>proof.export</code>,
<code>theme [light|dark]</code>, <code>status</code>, <code>clear</code>.</p>
<p style="font-size:12.5px; color:var(--ink-3);">
Press <kbd>/</kbd> to focus the REPL from anywhere. Arrow ↑/↓ recall
history (persisted across reloads). <kbd>⌘K</kbd> opens the command
palette with every action discoverable.</p>`,
},
{
icon: '🚀',
title: 'You are ready',
body: `<p style="font-size:14px;">That's the whole tour. A few last pointers:</p>
<ul style="margin:0 0 14px; padding-left:18px; font-size:13px; color:var(--ink-2); line-height:1.7;">
<li>Press <kbd>?</kbd> any time to open the help center
(Quickstart / Glossary / FAQ / Shortcuts / About).</li>
<li>Press <kbd>⌘K</kbd> for the command palette.</li>
<li>Press <kbd>\`</kbd> to toggle the debug HUD.</li>
<li>Settings (<kbd>⌘,</kbd>) lets you switch theme, density, motion,
transport, and replay this tour.</li>
</ul>
<p style="font-size:12.5px; color:var(--ink-3); line-height:1.55;">
Source: <code>github.com/ruvnet/RuView</code> · Apache-2.0 OR MIT ·
ADRs 089/090/091/092/093.</p>`,
cta: { label: 'Get started →' },
},
];
@customElement('nv-onboarding')
export class NvOnboarding extends LitElement {
@state() private open = false;
@state() private step = 0;
static styles = css`
:host {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
z-index: 240;
display: grid; place-items: center;
opacity: 0; pointer-events: none;
transition: opacity 0.18s;
}
:host([open]) { opacity: 1; pointer-events: auto; }
.card {
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(640px, 94vw);
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);
overflow: hidden;
}
:host([open]) .card { transform: translateY(0) scale(1); }
.h {
padding: 22px 26px 12px;
display: flex; align-items: flex-start; gap: 14px;
}
.h .icon {
width: 44px; height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
display: grid; place-items: center;
font-size: 22px;
flex-shrink: 0;
box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
}
.h .title-wrap { flex: 1; min-width: 0; }
.h h2 {
margin: 0;
font-size: 18px;
letter-spacing: -0.01em;
color: var(--ink);
}
.h .step-label {
font-family: var(--mono);
font-size: 10.5px;
color: var(--ink-3);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.h .skip {
width: 28px; height: 28px;
background: transparent;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink-2);
cursor: pointer;
flex-shrink: 0;
}
.h .skip:hover { color: var(--ink); border-color: var(--line-2); }
.body {
padding: 0 26px 16px;
font-size: 13px;
color: var(--ink-2);
line-height: 1.6;
overflow-y: auto;
flex: 1;
}
.body p { margin: 0 0 12px; }
.body p:last-child { margin-bottom: 0; }
.body code, .body kbd {
font-family: var(--mono);
font-size: 11.5px;
padding: 1px 5px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 4px;
}
.body code { color: var(--accent); }
.body kbd { color: var(--ink); }
.hint {
margin: 14px 0 0;
padding: 10px 12px;
background: oklch(0.78 0.12 195 / 0.06);
border: 1px solid oklch(0.78 0.12 195 / 0.25);
border-radius: 8px;
font-size: 12px;
color: var(--accent-2);
display: flex; gap: 8px; align-items: flex-start;
}
.hint::before {
content: '💡';
flex-shrink: 0;
}
.footer {
display: flex; align-items: center; gap: 14px;
padding: 14px 22px;
border-top: 1px solid var(--line);
background: var(--bg-1);
}
.progress { flex: 1; }
.dots { display: flex; gap: 5px; margin-bottom: 4px; }
.dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--bg-3);
border: 1px solid var(--line-2);
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
.dot.active {
background: var(--accent);
border-color: var(--accent);
transform: scale(1.2);
}
.dot.done {
background: var(--accent-4);
border-color: var(--accent-4);
}
.progress-label {
font-family: var(--mono);
font-size: 10px;
color: var(--ink-3);
}
button.primary, button.ghost {
padding: 9px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--ink);
}
button.ghost:hover { border-color: var(--line-2); }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #1a0f00;
}
button.primary:hover { filter: brightness(1.08); }
`;
override async connectedCallback(): Promise<void> {
super.connectedCallback();
window.addEventListener('nv-show-tour', this.show as EventListener);
const seen = await kvGet<boolean>('onboarding-seen');
if (!seen) {
this.open = true;
this.setAttribute('open', '');
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('nv-show-tour', this.show as EventListener);
}
private show = (): void => {
this.step = 0;
this.open = true;
this.setAttribute('open', '');
};
private async dismiss(): Promise<void> {
this.open = false;
this.removeAttribute('open');
await kvSet('onboarding-seen', true);
}
private next(): void {
const s = STEPS[this.step];
s.cta?.run?.();
if (this.step < STEPS.length - 1) this.step++;
else void this.dismiss();
}
private prev(): void {
if (this.step > 0) this.step--;
}
override render() {
const s = STEPS[this.step];
const isLast = this.step === STEPS.length - 1;
return html`
<div class="card" role="dialog" aria-modal="true" aria-label="Welcome tour">
<div class="h">
<div class="icon" aria-hidden="true">${s.icon}</div>
<div class="title-wrap">
<h2>${s.title}</h2>
<div class="step-label">Step ${this.step + 1} of ${STEPS.length}</div>
</div>
<button class="skip" @click=${() => this.dismiss()} aria-label="Skip tour" title="Skip tour">×</button>
</div>
<div class="body">
<div .innerHTML=${s.body}></div>
${s.hint ? html`<div class="hint">${s.hint}</div>` : ''}
</div>
<div class="footer">
<div class="progress">
<div class="dots">
${STEPS.map((_, i) => html`
<div class="dot ${i === this.step ? 'active' : i < this.step ? 'done' : ''}"></div>
`)}
</div>
<div class="progress-label">${this.step + 1} / ${STEPS.length}</div>
</div>
${this.step > 0
? html`<button class="ghost" @click=${() => this.prev()}>← Back</button>`
: html`<button class="ghost" @click=${() => this.dismiss()}>Skip</button>`}
<button class="primary" @click=${() => this.next()}>
${s.cta?.label ?? (isLast ? 'Done' : 'Next →')}
</button>
</div>
</div>
`;
}
}
+244
View File
@@ -0,0 +1,244 @@
/* 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: 'New scene…', kbd: '⌘N', run: () => openModal({
title: 'New scene',
body: `<p>Build a fresh magnetic scene. The dashboard generates the JSON
and pushes it to the running pipeline (or you can copy the JSON
for offline use).</p>
<label>Name</label>
<input type="text" id="ns-name" value="custom-scene-${Date.now().toString(36)}" />
<label>Heart-proxy dipole moment (A·m²)</label>
<input type="text" id="ns-moment" value="1.0e-6" />
<label>Distance heart → sensor (m)</label>
<input type="text" id="ns-distance" value="0.5" />
<label>Add ferrous distractor at +x = 1 m?</label>
<select id="ns-ferrous">
<option value="0">No</option>
<option value="1" selected>Yes (steel coil, χ=5000)</option>
</select>
<label>Add 60 Hz mains-current loop?</label>
<select id="ns-mains">
<option value="0">No</option>
<option value="1" selected>Yes (2 A loop, 5 cm radius, +y = 1 m)</option>
</select>`,
buttons: [
{ label: 'Cancel', variant: 'ghost' },
{ label: 'Create', variant: 'primary', onClick: async () => {
const root = document.querySelector('nv-app')?.shadowRoot?.querySelector('nv-modal')?.shadowRoot;
if (!root) return;
const name = (root.querySelector<HTMLInputElement>('#ns-name')?.value ?? 'custom').trim();
const m = parseFloat(root.querySelector<HTMLInputElement>('#ns-moment')?.value ?? '1e-6');
const d = parseFloat(root.querySelector<HTMLInputElement>('#ns-distance')?.value ?? '0.5');
const ferr = root.querySelector<HTMLSelectElement>('#ns-ferrous')?.value === '1';
const mains = root.querySelector<HTMLSelectElement>('#ns-mains')?.value === '1';
const scene = {
dipoles: [{ position: [0, 0, d] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
loops: mains ? [{
centre: [0, 1, 0] as [number, number, number],
normal: [0, 1, 0] as [number, number, number],
radius: 0.05, current: 2.0, n_segments: 64,
}] : [],
ferrous: ferr ? [{ position: [1, 0, 0] as [number, number, number], volume: 1e-4, susceptibility: 5000 }] : [],
eddy: [],
sensors: [[0, 0, 0] as [number, number, number]],
ambient_field: [1e-6, 0, 0] as [number, number, number],
};
await getClient()?.loadScene(scene);
pushLog('ok', `scene <span class="s">${name}</span> loaded · 1 dipole · ${mains ? '1 loop · ' : ''}${ferr ? '1 ferrous · ' : ''}1 sensor`);
toast(`Scene "${name}" loaded`, '+');
} },
],
}) },
{ ico: '📦', label: 'Export proof bundle…', kbd: '⌘E', run: async () => {
const c = getClient(); if (!c) return;
pushLog('dbg', 'building proof bundle…');
try {
const blob = await c.exportProofBundle();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nvsim-proof-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
pushLog('ok', `proof bundle exported · ${blob.size} bytes`);
toast(`Proof bundle saved (${blob.size} B)`, '📦');
} catch (e) { pushLog('err', `export failed: ${(e as Error).message}`); }
} },
{ 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>
`;
}
}
+116
View File
@@ -0,0 +1,116 @@
/* 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;
}
.btn.ghost.active::before { background: var(--accent-3); }
.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" aria-hidden="true">NV</div>
<nav role="navigation" aria-label="Primary"
style="display:flex; flex-direction:column; align-items:center; gap:4px; flex:1;">
<button class="btn ${this.view === 'home' ? 'active' : ''}"
data-id="home-btn" title="Home" aria-label="Home"
aria-current=${this.view === 'home' ? 'page' : 'false'}
@click=${() => this.navigate('home')}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12L12 4l9 8M5 10v10h14V10"/></svg>
</button>
<button class="btn ${this.view === 'scene' ? 'active' : ''}"
data-id="scene-btn" title="Scene" aria-label="Scene"
aria-current=${this.view === 'scene' ? 'page' : 'false'}
@click=${() => this.navigate('scene')}>
<svg viewBox="0 0 24 24" aria-hidden="true"><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" aria-label="App Store"
aria-current=${this.view === 'apps' ? 'page' : 'false'}
@click=${() => this.navigate('apps')}>
<svg viewBox="0 0 24 24" aria-hidden="true"><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 ${this.view === 'inspector' ? 'active' : ''}"
data-id="inspector-btn" title="Inspector" aria-label="Inspector"
aria-current=${this.view === 'inspector' ? 'page' : 'false'}
@click=${() => this.navigate('inspector')}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
</button>
<button class="btn ${this.view === 'witness' ? 'active' : ''}"
data-id="witness-btn" title="Witness" aria-label="Witness"
aria-current=${this.view === 'witness' ? 'page' : 'false'}
@click=${() => this.navigate('witness')}>
<svg viewBox="0 0 24 24" aria-hidden="true"><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>
<button class="btn ghost ${this.view === 'ghost-murmur' ? 'active' : ''}"
data-id="ghost-murmur-btn" title="Ghost Murmur — research spec"
aria-label="Ghost Murmur research"
aria-current=${this.view === 'ghost-murmur' ? 'page' : 'false'}
@click=${() => this.navigate('ghost-murmur')}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 2C5.7 2 3 4.7 3 8v12l3-2 3 2 3-2 3 2 3-2 3 2V8c0-3.3-2.7-6-6-6H9z"/>
<circle cx="9" cy="10" r="1.2" fill="currentColor"/>
<circle cx="15" cy="10" r="1.2" fill="currentColor"/>
</svg>
</button>
</nav>
<div class="spacer"></div>
<button class="btn" data-id="settings-btn" title="Settings" aria-label="Settings"
@click=${() => this.dispatchEvent(new CustomEvent('open-settings', { bubbles: true, composed: true }))}>
<svg viewBox="0 0 24 24" aria-hidden="true"><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>
`;
}
}
+374
View File
@@ -0,0 +1,374 @@
/* 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, running, getClient, speed, pushLog, lastFrame, scenePositions } 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 zoom = 1.0;
@state() private layerVisible = { source: true, field: true, label: true };
@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;
}
.scene-toolbar {
position: absolute; top: 14px; left: 14px;
display: flex; gap: 6px; z-index: 5;
background: rgba(13,17,23,0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--line);
border-radius: 8px;
padding: 4px;
}
[data-theme="light"] .scene-toolbar { background: rgba(255,255,255,0.85); }
.scene-toolbar button {
width: 28px; height: 28px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--ink-2);
cursor: pointer;
display: grid; place-items: center;
font-size: 13px;
}
.scene-toolbar button:hover { color: var(--ink); background: var(--bg-2); }
.scene-toolbar button.on { background: var(--bg-3); color: var(--accent); border-color: var(--line-2); }
.sim-controls {
position: absolute; bottom: 14px; right: 14px;
display: flex; gap: 6px; align-items: center;
background: rgba(13,17,23,0.85);
backdrop-filter: blur(12px);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 6px 10px;
z-index: 5;
}
[data-theme="light"] .sim-controls { background: rgba(255,255,255,0.92); }
.sim-controls .play {
width: 32px; height: 32px;
background: var(--accent);
border: none;
border-radius: 50%;
color: #1a0f00;
cursor: pointer;
display: grid; place-items: center;
font-size: 13px;
}
.sim-controls .play:hover { filter: brightness(1.08); }
.sim-controls .step {
width: 26px; height: 26px;
border-radius: 6px;
background: transparent;
color: var(--ink-2);
border: 1px solid var(--line);
cursor: pointer;
font-size: 11px;
}
.sim-controls .step:hover { color: var(--ink); border-color: var(--line-2); }
.sim-controls .speed {
font-family: var(--mono); font-size: 11px;
color: var(--ink-2);
padding: 0 6px;
min-width: 36px;
text-align: center;
cursor: pointer;
}
`;
override connectedCallback(): void {
super.connectedCallback();
// Restore drag positions if any are persisted.
if (scenePositions.value.length > 0) {
this.items = this.items.map((it) => {
const saved = scenePositions.value.find((p) => p.id === it.id);
return saved ? { ...it, x: saved.x, y: saved.y } : it;
});
}
effect(() => {
lastB.value; bMag.value; fps.value; snr.value; motionReduced.value;
running.value; speed.value; lastFrame.value;
this.requestUpdate();
});
// Compute SNR from the last frame: |B_pT| / max(σ_pT[k]) per ADR-093 P1.4.
effect(() => {
const f = lastFrame.value;
if (!f) return;
const bmag = Math.sqrt(f.bPt[0] ** 2 + f.bPt[1] ** 2 + f.bPt[2] ** 2);
const sigmaMax = Math.max(Math.abs(f.sigmaPt[0]), Math.abs(f.sigmaPt[1]), Math.abs(f.sigmaPt[2]), 0.001);
const snrVal = bmag / sigmaMax;
if (Number.isFinite(snrVal)) snr.value = snrVal;
});
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
window.addEventListener('keydown', this.onKey);
}
/** Tab cycles selection; arrow keys nudge by 8 px (32 px with Shift);
* Esc deselects. ADR-093 P2.6. */
private onKey = (e: KeyboardEvent): void => {
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
if (!this.selected) {
if (e.key === 'Tab' && document.activeElement === document.body) {
e.preventDefault();
this.selected = this.items[0]?.id ?? null;
}
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const step = e.shiftKey ? 32 : 8;
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
this.items = this.items.map((it) =>
it.id === this.selected
? { ...it, x: Math.max(20, Math.min(980, it.x + dx)), y: Math.max(20, Math.min(580, it.y + dy)) }
: it,
);
scenePositions.value = this.items.map(({ id, x, y }) => ({ id, x, y }));
} else if (e.key === 'Tab') {
e.preventDefault();
const idx = this.items.findIndex((it) => it.id === this.selected);
const next = (idx + (e.shiftKey ? -1 : 1) + this.items.length) % this.items.length;
this.selected = this.items[next].id;
} else if (e.key === 'Escape') {
this.selected = null;
}
};
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 stepFwd(): Promise<void> {
const c = getClient(); if (!c) return;
await c.step('fwd', 10);
pushLog('dbg', 'sim step → +1 frame');
}
private async stepBack(): Promise<void> {
const c = getClient(); if (!c) return;
await c.step('back', 10);
pushLog('dbg', 'sim step ← -1 frame');
}
private cycleSpeed(): void {
const speeds = [0.25, 0.5, 1.0, 2.0, 4.0];
const idx = speeds.indexOf(speed.value);
speed.value = speeds[(idx + 1) % speeds.length];
}
private zoomIn(): void { this.zoom = Math.min(2.5, this.zoom * 1.2); }
private zoomOut(): void { this.zoom = Math.max(0.5, this.zoom / 1.2); }
private fitView(): void { this.zoom = 1.0; }
private toggleLayer(k: 'source' | 'field' | 'label'): void {
this.layerVisible = { ...this.layerVisible, [k]: !this.layerVisible[k] };
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('keydown', this.onKey);
}
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 => {
if (this.dragging) {
// Persist all positions on drop.
scenePositions.value = this.items.map(({ id, x, y }) => ({ id, x, y }));
}
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';
const vbW = 1000 / this.zoom;
const vbH = 600 / this.zoom;
const vbX = (1000 - vbW) / 2;
const vbY = (600 - vbH) / 2;
return html`
<div class="grid"></div>
<svg viewBox="${vbX.toFixed(1)} ${vbY.toFixed(1)} ${vbW.toFixed(1)} ${vbH.toFixed(1)}"
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.layerVisible.field ? 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.layerVisible.source ? 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}/>
${this.layerVisible.label ? svg`<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-toolbar" id="scene-toolbar">
<button id="zoom-in-btn" title="Zoom in" @click=${this.zoomIn}>+</button>
<button id="zoom-out-btn" title="Zoom out" @click=${this.zoomOut}></button>
<button id="fit-btn" title="Fit to view" @click=${this.fitView}>⊡</button>
<button id="layer-source-btn" class=${this.layerVisible.source ? 'on' : ''}
title="Sources" @click=${() => this.toggleLayer('source')}>●</button>
<button id="layer-field-btn" class=${this.layerVisible.field ? 'on' : ''}
title="Field lines" @click=${() => this.toggleLayer('field')}>≈</button>
<button id="layer-label-btn" class=${this.layerVisible.label ? 'on' : ''}
title="Labels" @click=${() => this.toggleLayer('label')}>T</button>
</div>
<div class="sim-controls" id="sim-controls">
<button class="step" id="step-back-btn" title="Step back" @click=${this.stepBack}>⏮</button>
<button class="play" id="play-btn" title="Play / pause" @click=${this.toggleRun}>
${running.value ? '❚❚' : '▶'}
</button>
<button class="step" id="step-fwd-btn" title="Step forward" @click=${this.stepFwd}>⏭</button>
<span class="speed" id="speed-val" title="Cycle speed" @click=${this.cycleSpeed}>${speed.value}×</span>
</div>
<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>
`;
}
}
@@ -0,0 +1,261 @@
/* 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'); }
private async resetPrefs(): Promise<void> {
if (!confirm('Reset all preferences and IndexedDB state? Reloads the page.')) return;
try {
const dbs = await indexedDB.databases?.();
if (dbs) for (const d of dbs) if (d.name) indexedDB.deleteDatabase(d.name);
} catch { /* noop */ }
location.reload();
}
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 class="desc">Dark is the default; light has higher contrast for daylight work.</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 (15 / 14 / 13 px). Choose what your eyes prefer.</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">Stops the rotating diamond, animated field lines, and chart easing. Auto-on if your system has the prefers-reduced-motion preference set.</div>
</div>
<span class="toggle ${motionReduced.value ? 'on' : ''}"
role="switch" aria-checked=${motionReduced.value}
@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">When you change a Tunables slider or load a new scene, push the change to the worker without a manual restart.</div>
</div>
<span class="toggle ${autoUpdate.value ? 'on' : ''}"
role="switch" aria-checked=${autoUpdate.value}
@click=${() => autoUpdate.value = !autoUpdate.value}></span>
</div>
</div>
<div class="group">
<h4>Transport</h4>
<div class="row">
<div>
<div class="lbl">Mode</div>
<div class="desc">WASM runs nvsim in your browser (default, no server). WS connects to a host-supplied nvsim-server (REST + binary WebSocket); see ADR-092 §6.2.</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 class="desc">Where your nvsim-server is listening. The server defaults to 127.0.0.1:7878.</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 class="group">
<h4>Help</h4>
<div class="row">
<div>
<div class="lbl">Open help center</div>
<div class="desc">Quickstart, glossary, FAQ, and shortcuts. Press <kbd style="font-family:var(--mono);font-size:10.5px;padding:1px 4px;background:var(--bg-3);border:1px solid var(--line);border-radius:3px;">?</kbd> any time.</div>
</div>
<button class="seg"
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-help')); }}
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid var(--line);border-radius:6px;color:var(--ink);">
Open
</button>
</div>
<div class="row">
<div>
<div class="lbl">Replay welcome tour</div>
<div class="desc">Re-show the 6-step first-run walkthrough.</div>
</div>
<button class="seg"
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-tour')); }}
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid var(--line);border-radius:6px;color:var(--ink);">
Replay
</button>
</div>
<div class="row">
<div>
<div class="lbl">Reset all preferences</div>
<div class="desc">Wipe theme, density, motion, scene drag positions, REPL history, and the onboarding-seen flag.</div>
</div>
<button class="seg"
@click=${() => this.resetPrefs()}
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid oklch(0.65 0.22 25 / 0.4);border-radius:6px;color:var(--bad);">
Reset
</button>
</div>
</div>
<div class="group">
<h4>About</h4>
<div class="row" style="border-bottom:0;">
<div>
<div class="lbl">nvsim · v0.3.0</div>
<div class="desc">Open-source NV-diamond simulator. Apache-2.0 OR MIT.<br>
<a style="color:var(--accent-2); text-decoration:underline dotted; cursor:pointer;"
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section: 'about' } })); }}>
More info →
</a></div>
</div>
</div>
</div>
</div>
`;
}
}
+222
View File
@@ -0,0 +1,222 @@
/* 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, getClient, pushLog } from '../store/appStore';
let configPushTimer: number | null = null;
function pushConfigDebounced(): void {
if (configPushTimer !== null) window.clearTimeout(configPushTimer);
configPushTimer = window.setTimeout(async () => {
const c = getClient();
if (!c) return;
try {
await c.setConfig({
digitiser: { f_s_hz: fs.value, f_mod_hz: fmod.value },
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: !noiseEnabled.value,
},
dt_s: dtMs.value * 1e-3,
});
pushLog('dbg', `config pushed · fs=${fs.value} f_mod=${fmod.value} dt=${dtMs.value.toFixed(1)}ms noise=${noiseEnabled.value ? 'on' : 'off'}`);
} catch (e) {
pushLog('warn', `config push failed: ${(e as Error).message}`);
}
}, 300);
}
@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: 6px;
}
.panel-help {
font-size: 11.5px; color: var(--ink-3);
margin: 0 0 10px;
line-height: 1.5;
}
.help-link {
color: var(--accent-2);
cursor: pointer;
text-decoration: underline dotted;
}
.help-link:hover { color: var(--accent); }
.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="panel-help">
Magnetic primitives in the simulated environment. Drag any in the
canvas to reposition; positions persist across reloads.
</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="panel-help">
Element Six DNV-B1 reference: 1 mm³ diamond, ~10¹² NV centers.
Floor δB ≈ 1.18 pT/√Hz per Barry 2020 §III.A.
<span class="help-link" title="Open glossary"
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section: 'glossary' } }))}>What's NV?</span>
</div>
<div class="field-row" title="Sensing volume (cubic millimetres)"><span class="lbl">V</span><span class="val">1 mm³</span></div>
<div class="field-row" title="Number of NV centers contributing to readout"><span class="lbl">N</span><span class="val">1e12 NV</span></div>
<div class="field-row" title="ODMR contrast — fractional dip at resonance"><span class="lbl">C</span><span class="val">0.030</span></div>
<div class="field-row" title="Inhomogeneous dephasing time T₂*"><span class="lbl">T₂*</span><span class="val">200 ns</span></div>
<div class="field-row" title="Shot-noise-limited field sensitivity"><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="panel-help">
Live pipeline parameters. Edits debounce 300 ms then rebuild the
WASM pipeline without restarting the frame stream.
</div>
<div class="slider-row" title="Digitiser sample rate — frames per second emitted by the pipeline">
<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)}
aria-label="Sample rate in Hz"
@input=${(e: Event) => { fs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
</div>
<div class="slider-row" title="Microwave modulation frequency for lock-in demodulation">
<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)}
aria-label="Lock-in modulation frequency in Hz"
@input=${(e: Event) => { fmod.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
</div>
<div class="slider-row" title="Per-sample integration time">
<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)}
aria-label="Integration time in milliseconds"
@input=${(e: Event) => { dtMs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
</div>
<div class="slider-row" title="Toggle shot-noise sampling. OFF = analytic noise-free output (debug only)">
<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'}
aria-label="Shot-noise sampling enabled"
@input=${(e: Event) => { noiseEnabled.value = (e.target as HTMLInputElement).value === '1'; pushConfigDebounced(); }} />
</div>
</div>
<div class="panel">
<div class="panel-h">Pipeline</div>
<div class="panel-help">
Forward simulator stages, left to right. Stages glow cyan while
the pipeline is running.
</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>
`;
}
}
+64
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 } }));
}
+139
View File
@@ -0,0 +1,139 @@
/* 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, pushLog,
} from '../store/appStore';
import { openModal } from './nv-modal';
import { toast } from './nv-toast';
@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); cursor: pointer; }
.pill.seed:hover { border-color: var(--line-2); }
.pill.seed b { color: var(--accent); font-weight: 600; }
.pill.wasm { cursor: pointer; }
.pill.wasm:hover { border-color: var(--line-2); }
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';
}
private async openSeedModal(): Promise<void> {
const cur = `0x${seed.value.toString(16).toUpperCase().padStart(8, '0')}`;
openModal({
title: 'Set seed',
body: `<p>Set the 32-bit hex seed for the shot-noise PRNG. Same <code>(scene, config, seed)</code> → byte-identical witness.</p>
<label>Hex seed</label>
<input type="text" id="seed-input" value="${cur}" autofocus />`,
buttons: [
{ label: 'Cancel', variant: 'ghost' },
{ label: 'Apply', variant: 'primary', onClick: async () => {
const inp = document.querySelector('nv-modal')?.shadowRoot?.querySelector<HTMLInputElement>('#seed-input');
if (!inp) return;
const raw = inp.value.trim().replace(/^0x/i, '');
const v = BigInt('0x' + raw);
seed.value = v;
await getClient()?.setSeed(v);
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
toast(`Seed → 0x${v.toString(16).toUpperCase().slice(0, 8)}`, '⟳');
} },
],
});
}
private openTransportSettings(): void {
window.dispatchEvent(new CustomEvent('open-settings'));
}
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" title="Transport settings"
@click=${this.openTransportSettings}>
<span class="dot"></span>${transportLabel.value}
</span>
<span class="pill seed" id="seed-pill" title="Set seed"
@click=${this.openSeedModal}>
seed: <b>0x${seedHex}</b>
</span>
<button class="ghost" id="tour-btn" title="Replay the 10-step welcome tour"
aria-label="Replay welcome tour"
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-tour'))}>
★ Tour
</button>
<button class="ghost" id="help-btn" title="Help (press ? any time)" aria-label="Open help"
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-help'))}>
?
</button>
<button class="ghost" id="theme-btn" title="Toggle theme" aria-label="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>
`;
}
}
+200
View File
@@ -0,0 +1,200 @@
/* 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 { WsClient } from './transport/WsClient';
import type { NvsimClient, MagFrameBatch } from './transport/NvsimClient';
import {
setClient, transport, wsUrl, connected, transportError,
theme, density, motionReduced,
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
replHistory, scenePositions, type SceneItemPos,
activeAppIds, pushAppEvent,
} from './store/appStore';
import { APP_RUNTIMES, type AppRuntimeContext } from './store/appRuntimes';
import { kvGet, kvSet } from './store/persistence';
function applyTheme(t: string): void {
document.documentElement.setAttribute('data-theme', t);
}
function applyDensity(d: string): void {
document.body.classList.remove('density-comfy', 'density-default', 'density-compact');
document.body.classList.add(`density-${d}`);
}
function applyMotion(reduced: boolean): void {
document.body.classList.toggle('reduce-motion', reduced);
}
(async () => {
// Restore persisted prefs
const t = (await kvGet<'dark' | 'light'>('theme')) ?? 'dark';
const d = (await kvGet<'comfy' | 'default' | 'compact'>('density')) ?? 'default';
const sysMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const m = (await kvGet<boolean>('motionReduced')) ?? sysMotion;
theme.value = t; applyTheme(t);
density.value = d; applyDensity(d);
motionReduced.value = m; applyMotion(m);
// React to changes → persist
effect(() => { applyTheme(theme.value); kvSet('theme', theme.value); });
effect(() => { applyDensity(density.value); kvSet('density', density.value); });
effect(() => { applyMotion(motionReduced.value); kvSet('motionReduced', motionReduced.value); });
// REPL history + scene drag positions persistence (P0.10, P1.7)
const histSaved = await kvGet<string[]>('repl-history');
if (histSaved && Array.isArray(histSaved)) replHistory.value = histSaved;
effect(() => { void kvSet('repl-history', replHistory.value); });
const positionsSaved = await kvGet<SceneItemPos[]>('scene-positions');
if (positionsSaved && Array.isArray(positionsSaved)) scenePositions.value = positionsSaved;
effect(() => { void kvSet('scene-positions', scenePositions.value); });
// Restore WS URL preference + transport mode
const savedWsUrl = (await kvGet<string>('wsUrl')) ?? '';
if (savedWsUrl) wsUrl.value = savedWsUrl;
const savedTransport = (await kvGet<'wasm' | 'ws'>('transport')) ?? 'wasm';
transport.value = savedTransport;
effect(() => { void kvSet('wsUrl', wsUrl.value); });
effect(() => { void kvSet('transport', transport.value); });
// Per-app runtime scratch state + history buffer (defined first so the
// onFrames callback can close over them).
const appState: Record<string, Record<string, number>> = {};
const bMagHistory: number[] = [];
const runtimeStartTs = performance.now();
const onFrames = (batch: MagFrameBatch): void => {
if (batch.frames.length === 0) return;
const last = batch.frames[batch.frames.length - 1];
lastFrame.value = last;
const bx = last.bPt[0] * 1e-12;
const by = last.bPt[1] * 1e-12;
const bz = last.bPt[2] * 1e-12;
lastB.value = [bx, by, bz];
const bmagT = Math.sqrt(bx * bx + by * by + bz * bz);
bMag.value = bmagT;
pushTrace([bx * 1e9, by * 1e9, bz * 1e9]);
pushStripBar(Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3));
bMagHistory.push(bmagT);
while (bMagHistory.length > 256) bMagHistory.shift();
const activeIds = activeAppIds.value;
if (activeIds.size === 0) return;
const elapsedS = (performance.now() - runtimeStartTs) / 1000;
for (const id of activeIds) {
const fn = APP_RUNTIMES[id];
if (!fn) continue;
if (!appState[id]) appState[id] = {};
const ctx: AppRuntimeContext = {
frame: last,
bMagT: bmagT,
bRecoveredT: [bx, by, bz],
bHistory: bMagHistory,
elapsedS,
state: appState[id],
};
try {
const result = fn(ctx);
if (!result) continue;
const evs = Array.isArray(result) ? result : [result];
for (const ev of evs) {
pushAppEvent(ev);
pushLog('info',
`<span class="k">[${ev.appId}]</span> <span class="s">${ev.eventName}</span> <span class="n">(${ev.eventId})</span>${ev.detail ? ' · ' + ev.detail : ''}`);
}
} catch (e) {
pushLog('warn', `[${id}] runtime error: ${(e as Error).message}`);
}
}
};
// Boot transport (WASM by default, WS if user previously selected it)
let activeClient: NvsimClient | null = null;
async function bootTransport(): Promise<void> {
try {
if (activeClient) await activeClient.close();
const want = transport.value;
if (want === 'ws' && wsUrl.value.trim()) {
const c = new WsClient(wsUrl.value.trim());
const info = await c.boot();
activeClient = c;
connected.value = true;
transportError.value = null;
expectedWitness.value = info.expectedWitnessHex;
wireClient(c);
pushLog('ok', `transport WS · ${wsUrl.value} · nvsim@${info.buildVersion}`);
} else {
if (want === 'ws') {
pushLog('warn', 'WS transport selected but no URL set — falling back to WASM');
}
const c = new WasmClient();
const info = await c.boot();
activeClient = c;
connected.value = true;
transportError.value = null;
expectedWitness.value = info.expectedWitnessHex;
wireClient(c);
pushLog('ok', `transport WASM · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
}
setClient(activeClient);
} catch (e) {
const msg = (e as Error).message;
transportError.value = msg;
connected.value = false;
pushLog('err', `transport boot failed: ${msg}`);
}
}
function wireClient(c: NvsimClient): void {
c.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);
});
c.onFrames(onFrames);
}
// React to transport-mode flips: tear down + re-boot.
let bootInProgress = false;
effect(() => {
transport.value; wsUrl.value;
if (bootInProgress) return;
bootInProgress = true;
void bootTransport().finally(() => { bootInProgress = false; });
});
pushLog('info', 'nvsim — booting transport');
// Initial boot — handled by the effect() above.
// Auto-verify witness whenever a fresh transport boot completes.
let verifiedFor: string | null = null;
effect(() => {
const exp = expectedWitness.value;
const isConn = connected.value;
if (!exp || !isConn) return;
if (verifiedFor === exp) return;
verifiedFor = exp;
void (async () => {
const c = activeClient;
if (!c) return;
try {
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) {
witnessHex.value = exp;
pushLog('ok', `witness verified · determinism gate ✓ · transport=${transport.value}`);
} 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}`);
}
})();
});
sceneJson.value = '(reference scene)';
})();
+236
View File
@@ -0,0 +1,236 @@
/* In-browser simulated runtimes for App Store apps.
*
* Each runtime takes the most recent nvsim MagFrame + a short rolling
* history and decides whether to emit one or more app events. Outputs are
* illustrative: nvsim produces magnetic-field samples, the wasm-edge
* algorithms expect WiFi CSI subcarriers — different physical modalities.
* The simulated runtime preserves *event-emission semantics* (the same
* i32 event IDs, the same trigger logic shape) so users can see the
* cards working without an ESP32 mesh.
*
* For engineering-grade output, deploy the real `wifi-densepose-wasm-edge`
* crate to ESP32 firmware over the WS transport — see ADR-040 / ADR-092 §6.2.
*/
import type { MagFrameRecord } from '../transport/NvsimClient';
export interface AppEvent {
/** Wall-clock timestamp (ms). */
ts: number;
/** App id that emitted. */
appId: string;
/** i32 event id from `event_types` mod in wifi-densepose-wasm-edge. */
eventId: number;
/** Human-readable event name (matches the constant name). */
eventName: string;
/** Numeric value the app reports (units app-specific). */
value: number;
/** Optional extra context for the console line. */
detail?: string;
}
export interface AppRuntimeContext {
frame: MagFrameRecord;
bMagT: number;
bRecoveredT: [number, number, number];
/** Rolling history of |B| in T. Most recent last. */
bHistory: number[];
/** Time since the runtime was activated (s). */
elapsedS: number;
/** Per-app scratch state — runtimes can persist counters here. */
state: Record<string, number>;
}
export type AppRuntimeFn = (ctx: AppRuntimeContext) => AppEvent | AppEvent[] | null;
/** Welford-style running-stat helper. */
function rollingMean(arr: number[]): number {
if (arr.length === 0) return 0;
let s = 0;
for (const v of arr) s += v;
return s / arr.length;
}
function rollingStd(arr: number[]): number {
if (arr.length < 2) return 0;
const m = rollingMean(arr);
let s = 0;
for (const v of arr) s += (v - m) * (v - m);
return Math.sqrt(s / (arr.length - 1));
}
/** vital_trend — periodic 1-Hz HR/BR estimate from the B_z oscillation. */
const vitalTrend: AppRuntimeFn = (ctx) => {
if (ctx.bHistory.length < 64) return null;
const last = ctx.state['lastEmitS'] ?? 0;
if (ctx.elapsedS - last < 1.0) return null;
ctx.state['lastEmitS'] = ctx.elapsedS;
// Crude HR estimate: count zero-crossings of detrended B_z over the last
// 64 samples; treat each crossing pair as one cardiac cycle.
const tail = ctx.bHistory.slice(-64);
const m = rollingMean(tail);
let crossings = 0;
for (let i = 1; i < tail.length; i++) {
if ((tail[i] - m) * (tail[i - 1] - m) < 0) crossings++;
}
// 64 samples ≈ 0.65 s at the worker's 32-frame batches × 16 ms tick.
const cycles = crossings / 2;
const hr = Math.max(40, Math.min(180, Math.round((cycles / 0.65) * 60)));
const br = Math.max(8, Math.min(30, Math.round(hr / 4))); // crude proxy
const evs: AppEvent[] = [
{ ts: Date.now(), appId: 'vital_trend', eventId: 100, eventName: 'VITAL_TREND', value: hr, detail: `HR≈${hr} BPM, BR≈${br} br/min` },
];
if (hr < 60) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 103, eventName: 'BRADYCARDIA', value: hr, detail: `HR=${hr} BPM` });
else if (hr > 100) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 104, eventName: 'TACHYCARDIA', value: hr, detail: `HR=${hr} BPM` });
if (br < 12) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 101, eventName: 'BRADYPNEA', value: br, detail: `BR=${br} br/min` });
else if (br > 24) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 102, eventName: 'TACHYPNEA', value: br, detail: `BR=${br} br/min` });
return evs;
};
/** occupancy — variance threshold on |B| over a 5-second window. */
const occupancy: AppRuntimeFn = (ctx) => {
if (ctx.bHistory.length < 32) return null;
const last = ctx.state['lastEmitS'] ?? 0;
if (ctx.elapsedS - last < 2.0) return null;
const std = rollingStd(ctx.bHistory.slice(-128)) * 1e9; // T → nT
const occupied = std > 0.01; // empirical threshold for the demo
const wasOccupied = (ctx.state['occ'] ?? 0) > 0.5;
if (occupied !== wasOccupied) {
ctx.state['occ'] = occupied ? 1 : 0;
ctx.state['lastEmitS'] = ctx.elapsedS;
return {
ts: Date.now(),
appId: 'occupancy',
eventId: occupied ? 300 : 302,
eventName: occupied ? 'ZONE_OCCUPIED' : 'ZONE_TRANSITION',
value: std,
detail: occupied ? `σ(|B|)=${std.toFixed(3)} nT — entered` : `σ(|B|)=${std.toFixed(3)} nT — left`,
};
}
return null;
};
/** intrusion — |B| above ambient + dwell timer. */
const intrusion: AppRuntimeFn = (ctx) => {
const ambient = ctx.state['ambient'] ?? ctx.bMagT;
ctx.state['ambient'] = 0.95 * ambient + 0.05 * ctx.bMagT;
const exceeds = ctx.bMagT > ambient * 1.5 && ctx.bMagT > 1e-12;
const dwellStart = ctx.state['dwellStart'] ?? 0;
if (exceeds && dwellStart === 0) {
ctx.state['dwellStart'] = ctx.elapsedS;
} else if (!exceeds) {
ctx.state['dwellStart'] = 0;
}
if (exceeds && dwellStart > 0 && ctx.elapsedS - dwellStart > 0.5 && (ctx.state['lastEmitS'] ?? 0) < dwellStart) {
ctx.state['lastEmitS'] = ctx.elapsedS;
return {
ts: Date.now(),
appId: 'intrusion',
eventId: 200,
eventName: 'INTRUSION_ALERT',
value: ctx.bMagT * 1e9,
detail: `|B|=${(ctx.bMagT * 1e9).toFixed(2)} nT > 1.5× ambient (${(ambient * 1e9).toFixed(2)} nT) for ${(ctx.elapsedS - dwellStart).toFixed(1)} s`,
};
}
return null;
};
/** coherence — z-score of recent |B| against a longer baseline. */
const coherence: AppRuntimeFn = (ctx) => {
if (ctx.bHistory.length < 64) return null;
const last = ctx.state['lastEmitS'] ?? 0;
if (ctx.elapsedS - last < 0.5) return null;
ctx.state['lastEmitS'] = ctx.elapsedS;
const recent = ctx.bHistory.slice(-32);
const baseline = ctx.bHistory.slice(-128, -32);
if (baseline.length < 32) return null;
const mu = rollingMean(baseline);
const sd = rollingStd(baseline);
if (sd === 0) return null;
const recentMean = rollingMean(recent);
const z = Math.abs(recentMean - mu) / sd;
return {
ts: Date.now(),
appId: 'coherence',
eventId: 2,
eventName: 'COHERENCE_SCORE',
value: z,
detail: `z=${z.toFixed(2)} σ ${z > 3 ? '· DRIFT' : z > 1.5 ? '· marginal' : '· stable'}`,
};
};
/** adversarial — detect physically-impossible 1/r³ violation. */
const adversarial: AppRuntimeFn = (ctx) => {
if (ctx.bHistory.length < 32) return null;
const last = ctx.state['lastEmitS'] ?? 0;
if (ctx.elapsedS - last < 3.0) return null;
// Fake "multi-link consistency": compare instantaneous |B| with the
// smoothed |B|. A sharp factor-of-N step violates dipole physics
// (real 1/r³ source moves continuously).
const tail = ctx.bHistory.slice(-32);
let maxJump = 0;
for (let i = 1; i < tail.length; i++) {
const j = Math.abs(Math.log(Math.max(tail[i], 1e-15)) - Math.log(Math.max(tail[i - 1], 1e-15)));
if (j > maxJump) maxJump = j;
}
if (maxJump > 5) {
ctx.state['lastEmitS'] = ctx.elapsedS;
return {
ts: Date.now(),
appId: 'adversarial',
eventId: 3,
eventName: 'ANOMALY_DETECTED',
value: maxJump,
detail: `log-jump ${maxJump.toFixed(1)} — physically implausible step in |B|`,
};
}
return null;
};
/** exo_ghost_hunter — empty-room CSI anomaly detector adapted to the
* magnetic noise floor: flag impulsive / periodic / drift / random
* patterns and a hidden-presence sub-detector at 0.15-0.5 Hz. */
const exoGhostHunter: AppRuntimeFn = (ctx) => {
if (ctx.bHistory.length < 128) return null;
const last = ctx.state['lastEmitS'] ?? 0;
if (ctx.elapsedS - last < 4.0) return null;
ctx.state['lastEmitS'] = ctx.elapsedS;
const tail = ctx.bHistory.slice(-128);
const std = rollingStd(tail) * 1e9;
// Detect impulsive: max - mean > 4σ
const m = rollingMean(tail);
let maxDev = 0;
for (const v of tail) {
const d = Math.abs(v - m);
if (d > maxDev) maxDev = d;
}
const cls: 1 | 3 | 4 = maxDev > 4 * (std * 1e-9) ? 1 // impulsive
: ctx.elapsedS > 10 ? 3 // drift bias as a default after warmup
: 4; // random
const clsName = cls === 1 ? 'impulsive' : cls === 3 ? 'drift' : 'random';
return {
ts: Date.now(),
appId: 'exo_ghost_hunter',
eventId: 651,
eventName: 'ANOMALY_CLASS',
value: cls,
detail: `class=${clsName} · σ=${std.toFixed(3)} nT`,
};
};
export const APP_RUNTIMES: Record<string, AppRuntimeFn> = {
vital_trend: vitalTrend,
occupancy,
intrusion,
coherence,
adversarial,
exo_ghost_hunter: exoGhostHunter,
};
export function hasRuntime(appId: string): boolean {
return appId in APP_RUNTIMES;
}
+137
View File
@@ -0,0 +1,137 @@
/* 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');
/** REPL command history, persisted via persistence.ts (kvSet 'repl-history'). */
export const replHistory = signal<string[]>([]);
export function pushReplHistory(cmd: string): void {
const next = replHistory.value.slice();
next.push(cmd);
while (next.length > 200) next.shift();
replHistory.value = next;
}
/** Scene drag positions, persisted via persistence.ts (kvSet 'scene-positions'). */
export interface SceneItemPos { id: string; x: number; y: number }
export const scenePositions = signal<SceneItemPos[]>([]);
/** App-runtime emitted events. See appRuntimes.ts. */
import type { AppEvent } from './appRuntimes';
export const appEvents = signal<AppEvent[]>([]);
export const appEventCounts = signal<Record<string, number>>({});
export function pushAppEvent(ev: AppEvent): void {
const next = appEvents.value.slice();
next.push(ev);
while (next.length > 200) next.shift();
appEvents.value = next;
const c = { ...appEventCounts.value };
c[ev.appId] = (c[ev.appId] ?? 0) + 1;
appEventCounts.value = c;
}
/** Active app activations — driven by the App Store toggles. Mirrored
* from `apps.ts` but exposed as a signal here so `main.ts` can dispatch
* frames to active runtimes without importing the App Store component. */
export const activeAppIds = signal<Set<string>>(new Set());
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.
}
+331
View File
@@ -0,0 +1,331 @@
/* 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';
/** What actually happens when a card's toggle is on.
* - `running` — the algorithm is genuinely running in the browser right now
* (e.g. `nvsim` itself, which is the simulator the dashboard fronts).
* - `simulated` — a pared-down version of the algorithm runs against nvsim's
* live magnetic frame stream as a *proxy* for its native CSI input.
* Emits real i32 event IDs into the console feed; output is illustrative,
* not engineering-grade. Listed apps' Rust source is real, builds for
* wasm32-unknown-unknown, and passes its native unit tests.
* - `mesh-only` — algorithm needs CSI subcarrier data from a real ESP32-S3
* mesh (or a future CSI simulator). Toggling persists the selection so
* the WS transport can push activation when connected. */
export type AppRuntime = 'running' | 'simulated' | 'mesh-only';
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;
/** What actually happens when active — see AppRuntime docs. */
runtime?: AppRuntime;
}
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',
runtime: 'running',
},
// ── 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',
runtime: 'mesh-only',
},
{
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',
runtime: 'simulated',
},
{
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',
runtime: 'simulated',
},
{
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'],
runtime: 'simulated',
},
{
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',
runtime: 'simulated',
},
{
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'],
runtime: 'simulated',
},
// ── 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', runtime: 'simulated' },
{ 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;
}
+52
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);
});
}
+143
View File
@@ -0,0 +1,143 @@
/* 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?: {
gamma_fwhm_hz?: number;
t1_s?: number;
t2_s?: number;
t2_star_s?: number;
contrast?: number;
n_spins?: number;
n_centers?: 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 }
/** One-shot pipeline run for "what would the sensor recover at this scene?"
* use cases. Doesn't disturb the running pipeline. */
export interface TransientRunResult {
bRecoveredT: [number, number, number];
bMagT: number;
noiseFloorPtSqrtHz: number;
sigmaPt: [number, number, number];
nFrames: number;
witnessHex: string;
}
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>;
runTransient(scene: SceneJson, config: PipelineConfigJson, seed: bigint, samples: number): Promise<TransientRunResult>;
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;
}
+218
View File
@@ -0,0 +1,218 @@
/* 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,
type TransientRunResult,
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;
// Pass Vite's resolved BASE_URL so the worker can locate /nvsim-pkg/
// under the same prefix the dashboard is served from (e.g. /RuView/nvsim/
// on GitHub Pages, "/" in dev).
const base = import.meta.env.BASE_URL ?? '/';
const r = await this.rpc<{ buildVersion: string; frameMagic: number; frameBytes: number; expectedWitnessHex: string }>(
{ type: 'boot', base },
);
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 runTransient(
scene: SceneJson,
config: PipelineConfigJson,
seed: bigint,
samples: number,
): Promise<TransientRunResult> {
const r = await this.rpc<{
bRecoveredT: number[];
bMagT: number;
noiseFloorPtSqrtHz: number;
sigmaPt: number[];
nFrames: number;
witnessHex: string;
}>({
type: 'runTransient',
scene: JSON.stringify(scene),
config: JSON.stringify(config),
seed: Number(seed & 0xFFFFFFFFn),
samples,
});
return {
bRecoveredT: [r.bRecoveredT[0], r.bRecoveredT[1], r.bRecoveredT[2]],
bMagT: r.bMagT,
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
sigmaPt: [r.sigmaPt[0], r.sigmaPt[1], r.sigmaPt[2]],
nFrames: r.nFrames,
witnessHex: r.witnessHex,
};
}
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();
}
}
+227
View File
@@ -0,0 +1,227 @@
/* WebSocket transport client — talks to a `nvsim-server` Axum host
* (v2/crates/nvsim-server). REST for control plane, binary WebSocket
* for the MagFrame stream. Mirrors the WasmClient interface so the
* dashboard can swap transports at runtime without code changes.
*
* ADR-092 §5.2 / §6.2.
*/
import {
type NvsimClient,
type SceneJson,
type PipelineConfigJson,
type RunOpts,
type MagFrameBatch,
type NvsimEvent,
type TransientRunResult,
parseFrameBatch,
} from './NvsimClient';
interface HealthBody {
nvsim_version: string;
magic: number;
frame_bytes: number;
expected_witness_hex: string;
}
interface VerifyBody {
ok: boolean;
actual_hex: string;
expected_hex: string;
}
interface WitnessBody {
witness_hex: string;
samples: number;
seed_hex: string;
}
export interface WsBootInfo {
buildVersion: string;
frameMagic: number;
frameBytes: number;
expectedWitnessHex: string;
}
/** Convert a base URL (e.g. `http://host:7878`) to its WebSocket peer (`ws://host:7878`). */
function toWsUrl(baseUrl: string): string {
if (baseUrl.startsWith('ws://') || baseUrl.startsWith('wss://')) return baseUrl;
return baseUrl.replace(/^http/, 'ws');
}
export class WsClient implements NvsimClient {
private baseUrl: string;
private wsUrl: string;
private ws: WebSocket | null = null;
private bootInfo: WsBootInfo | null = null;
private frameSubs = new Set<(b: MagFrameBatch) => void>();
private eventSubs = new Set<(e: NvsimEvent) => void>();
private running = false;
private framesEmitted = 0;
private fpsLast = performance.now();
private fpsCount = 0;
/** @param baseUrl e.g. `http://localhost:7878` */
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.wsUrl = `${toWsUrl(this.baseUrl)}/ws/stream`;
}
private async json<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(`${path}: ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
async boot(): Promise<WsBootInfo> {
if (this.bootInfo) return this.bootInfo;
const h = await this.json<HealthBody>('/api/health');
this.bootInfo = {
buildVersion: h.nvsim_version,
frameMagic: h.magic,
frameBytes: h.frame_bytes,
expectedWitnessHex: h.expected_witness_hex,
};
this.openWs();
return this.bootInfo;
}
private openWs(): void {
if (this.ws) return;
const ws = new WebSocket(this.wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
this.eventSubs.forEach((s) =>
s({ type: 'log', level: 'ok', msg: `ws/stream connected · ${this.wsUrl}` }),
);
};
ws.onclose = () => {
this.ws = null;
this.eventSubs.forEach((s) =>
s({ type: 'log', level: 'warn', msg: 'ws/stream closed' }),
);
};
ws.onerror = () => {
this.eventSubs.forEach((s) =>
s({ type: 'log', level: 'err', msg: `ws/stream error · ${this.wsUrl}` }),
);
};
ws.onmessage = (ev: MessageEvent) => {
if (!(ev.data instanceof ArrayBuffer)) return;
const bytes = new Uint8Array(ev.data);
const frames = parseFrameBatch(bytes);
if (frames.length === 0) return;
const batch: MagFrameBatch = { frames, bytes };
this.frameSubs.forEach((s) => s(batch));
this.framesEmitted += frames.length;
this.fpsCount += frames.length;
const now = performance.now();
if (now - this.fpsLast >= 1000) {
const fps = (this.fpsCount * 1000) / (now - this.fpsLast);
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
this.fpsLast = now;
this.fpsCount = 0;
}
};
this.ws = ws;
}
async loadScene(scene: SceneJson): Promise<void> {
await this.json('/api/scene', { method: 'PUT', body: JSON.stringify(scene) });
}
async setConfig(cfg: PipelineConfigJson): Promise<void> {
await this.json('/api/config', { method: 'PUT', body: JSON.stringify(cfg) });
}
async setSeed(seed: bigint): Promise<void> {
await this.json('/api/seed', {
method: 'PUT',
body: JSON.stringify({ seed_hex: '0x' + seed.toString(16).toUpperCase().padStart(16, '0') }),
});
}
async reset(): Promise<void> {
await this.json('/api/reset', { method: 'POST' });
this.running = false;
this.framesEmitted = 0;
this.eventSubs.forEach((s) => s({ type: 'state', running: false, t: 0, framesEmitted: 0 }));
}
async run(_opts?: RunOpts): Promise<void> {
await this.json('/api/run', { method: 'POST' });
this.running = true;
this.eventSubs.forEach((s) =>
s({ type: 'state', running: true, t: 0, framesEmitted: this.framesEmitted }),
);
}
async pause(): Promise<void> {
await this.json('/api/pause', { method: 'POST' });
this.running = false;
this.eventSubs.forEach((s) =>
s({ type: 'state', running: false, t: 0, framesEmitted: this.framesEmitted }),
);
}
async step(direction: 'fwd' | 'back', dtMs: number): Promise<void> {
await this.json('/api/step', { method: 'POST', body: JSON.stringify({ direction, dt_ms: dtMs }) });
}
onFrames(cb: (b: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
onEvent(cb: (e: NvsimEvent) => void): void { this.eventSubs.add(cb); }
async generateWitness(samples: number): Promise<Uint8Array> {
const r = await this.json<WitnessBody>('/api/witness/generate', {
method: 'POST',
body: JSON.stringify({ samples }),
});
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(r.witness_hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
const expected_hex = Array.from(expected).map((b) => b.toString(16).padStart(2, '0')).join('');
const r = await this.json<VerifyBody>('/api/witness/verify', {
method: 'POST',
body: JSON.stringify({ expected_hex, samples: 256 }),
});
if (r.ok) return { ok: true };
const actual = new Uint8Array(32);
for (let i = 0; i < 32; i++) actual[i] = parseInt(r.actual_hex.slice(i * 2, i * 2 + 2), 16);
return { ok: false, actual };
}
async exportProofBundle(): Promise<Blob> {
const text = await fetch(`${this.baseUrl}/api/export-proof`, { method: 'POST' }).then((r) => r.text());
return new Blob([text], { type: 'application/json' });
}
async runTransient(
scene: SceneJson,
config: PipelineConfigJson,
_seed: bigint,
samples: number,
): Promise<TransientRunResult> {
// Server doesn't expose a transient route in V1 — the dashboard's
// Ghost Murmur sandbox falls back to the WASM client when transport
// is WS. Stub here returns a zero-result so the caller can detect.
void scene; void config; void samples;
return {
bRecoveredT: [0, 0, 0],
bMagT: 0,
noiseFloorPtSqrtHz: 0,
sigmaPt: [0, 0, 0],
nFrames: 0,
witnessHex: '(transient route not available in WS transport — V1 limitation)',
};
}
async buildId(): Promise<string> {
const info = this.bootInfo ?? (await this.boot());
return `nvsim@${info.buildVersion} (ws)`;
}
async close(): Promise<void> {
this.ws?.close();
this.ws = null;
}
}
+284
View File
@@ -0,0 +1,284 @@
/* 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 TransientResult {
bRecoveredT: Float64Array;
bMagT: number;
noiseFloorPtSqrtHz: number;
sigmaPt: Float64Array;
nFrames: number;
witnessHex: string;
}
interface NvsimPkg {
default: (input?: unknown) => Promise<unknown>;
WasmPipeline: WasmPipelineStatic;
referenceSceneJson: () => string;
expectedReferenceWitnessHex: () => string;
hexWitness: (b: Uint8Array) => string;
referenceWitness: () => Uint8Array;
runTransient: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
}
let _WasmPipeline!: WasmPipelineStatic;
let referenceSceneJson!: () => string;
let expectedReferenceWitnessHex!: () => string;
let hexWitness!: (b: Uint8Array) => string;
let referenceWitness!: () => Uint8Array;
let runTransient!: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
async function loadPkg(base: string): Promise<void> {
// `base` is the dashboard's BASE_URL injected by Vite, prefixed with the
// origin so we get an absolute URL the dynamic import can resolve. In dev
// this is "/", in prod under GitHub Pages it's "/RuView/nvsim/".
const absoluteBase = new URL(base, ws.location.origin).href;
const pkgUrl = new URL('nvsim-pkg/nvsim.js', absoluteBase).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;
runTransient = pkg.runTransient;
}
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': {
const base = (m.base as string | undefined) ?? '/';
await loadPkg(base);
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 'runTransient': {
const sceneJson = m.scene as string;
const configJson = m.config as string;
const seed = (m.seed as number) ?? 0;
const samples = (m.samples as number) ?? 64;
const r = runTransient(sceneJson, configJson, seed, samples);
post({
type: 'transient',
id: m.id,
bRecoveredT: Array.from(r.bRecoveredT),
bMagT: r.bMagT,
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
sigmaPt: Array.from(r.sigmaPt),
nFrames: r.nFrames,
witnessHex: r.witnessHex,
});
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' });
+56
View File
@@ -0,0 +1,56 @@
/* axe-core accessibility smoke against the built dashboard.
* Closes ADR-092 §11.5 — formal axe scan.
*
* Runs against `npm run preview` (Vite preview server). Validates each
* primary view (home / scene / apps / inspector / witness / ghost-murmur)
* and asserts 0 critical/serious violations.
*/
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const VIEWS = ['home', 'scene', 'apps', 'inspector', 'witness', 'ghost-murmur'] as const;
test.describe('axe-core a11y smoke', () => {
for (const view of VIEWS) {
test(`view: ${view}`, async ({ page }) => {
await page.goto('/');
// Dismiss the welcome modal if it auto-shows.
await page.evaluate(() => {
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
const ob = sr.querySelector('nv-onboarding') as HTMLElement | null;
if (ob?.hasAttribute('open')) {
(ob.shadowRoot?.querySelector('.skip') as HTMLElement | null)?.click();
}
});
// Navigate to the view via the rail button (except for home which is default).
if (view !== 'home') {
await page.evaluate((v) => {
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
const rail = sr.querySelector('nv-rail') as HTMLElement & { shadowRoot: ShadowRoot };
const btn = rail.shadowRoot.querySelector(`button[data-id=${v}-btn]`) as HTMLElement | null;
btn?.click();
}, view);
await page.waitForTimeout(300);
}
const results = await new AxeBuilder({ page })
.options({ runOnly: ['wcag2a', 'wcag2aa'] })
.analyze();
const critical = results.violations.filter((v) => v.impact === 'critical');
const serious = results.violations.filter((v) => v.impact === 'serious');
// Logging the violation summary makes CI failures readable.
if (critical.length || serious.length) {
for (const v of [...critical, ...serious]) {
console.error(`[${view}] ${v.impact} · ${v.id} · ${v.help}`);
for (const node of v.nodes) console.error(` ${node.target.join(' >> ')}`);
}
}
expect(critical.length, 'no critical violations').toBe(0);
expect(serious.length, 'no serious violations').toBe(0);
});
}
});
+25
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"]
}
+80
View File
@@ -0,0 +1,80 @@
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
// 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',
},
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: [
'nvsim-pkg/nvsim.js',
'nvsim-pkg/nvsim_bg.wasm',
],
manifest: {
name: 'nvsim — NV-Diamond Magnetometer Simulator',
short_name: 'nvsim',
description: 'Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs.',
theme_color: '#0d1117',
background_color: '#0d1117',
display: 'standalone',
scope: base,
start_url: base,
icons: [
{
src: 'icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any maskable',
},
{
src: 'icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,wasm,woff,woff2}'],
// WASM is large; bump the precache size budget so workbox doesn't
// skip nvsim_bg.wasm.
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
},
devOptions: {
enabled: false,
},
}),
],
build: {
target: 'es2022',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
lit: ['lit'],
signals: ['@preact/signals-core'],
},
},
},
},
server: {
port: 5173,
strictPort: true,
fs: {
allow: ['..', '.'],
},
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});