mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
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:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
public/nvsim-pkg
|
||||
@@ -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>
|
||||
Generated
+6525
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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; }
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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></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></span>
|
||||
<input id="console-input" type="text"
|
||||
placeholder="help · scene.list · sensor.config · run · proof.verify · clear"
|
||||
@keydown=${this.onKey}/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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.1–2 m (lab)
|
||||
│ magnetometer ring │ Status: nvsim simulator only
|
||||
│ (close-confirm) │ Hardware: $$$ (≥$8k DNV-B1)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
┌──────────┴───────────────┐
|
||||
│ Tier 2 — 60 GHz FMCW │ Range: 1–10 m HR/BR
|
||||
│ mmWave radar mesh │ Status: shipping (ADR-021)
|
||||
│ (vital signs, posture) │ Hardware: $15 (MR60BHA2 + ESP32-C6)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
┌──────────┴───────────────┐
|
||||
│ Tier 1 — WiFi CSI mesh │ Range: 10–30 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">1–5 m HR · 10–30 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, 0–15 m)</span></span><br><br>
|
||||
<span class="stat"><span class="v">±2 bpm</span><span class="l">HR (LOS 0–3 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">80–150 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 < $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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 } }));
|
||||
}
|
||||
@@ -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 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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> (1–100 kHz) — digitiser frame rate</li>
|
||||
<li><b>Lock-in f_mod</b> (0.1–5 kHz) — microwave modulation freq</li>
|
||||
<li><b>Integration t</b> (0.1–10 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 } }));
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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 100–199 Medical & health
|
||||
* sec 200–299 Security & safety
|
||||
* bld 300–399 Smart building
|
||||
* ret 400–499 Retail & hospitality
|
||||
* ind 500–599 Industrial
|
||||
* sig 600–619 Signal-processing primitives
|
||||
* lrn 620–639 Online learning
|
||||
* spt 640–659 Spatial / graph
|
||||
* tmp 640–660 Temporal logic / planning
|
||||
* ais 700–719 AI safety
|
||||
* qnt 720–739 Quantum-flavoured signal
|
||||
* aut 740–759 Autonomy / mesh
|
||||
* exo 650–699 Exotic / research
|
||||
* sim — Pipeline simulators (nvsim)
|
||||
*
|
||||
* The `crate` field names the Cargo crate that owns the implementation.
|
||||
* `wasmEdge` apps are compiled out of `wifi-densepose-wasm-edge`;
|
||||
* `nvsim` apps come from `nvsim`. Future apps may target other crates.
|
||||
*/
|
||||
|
||||
export type AppCategory =
|
||||
| 'sim'
|
||||
| 'med'
|
||||
| 'sec'
|
||||
| 'bld'
|
||||
| 'ret'
|
||||
| 'ind'
|
||||
| 'sig'
|
||||
| 'lrn'
|
||||
| 'spt'
|
||||
| 'tmp'
|
||||
| 'ais'
|
||||
| 'qnt'
|
||||
| 'aut'
|
||||
| 'exo';
|
||||
|
||||
/** 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 → Biot–Savart → NV ensemble → ADC → MagFrame stream + SHA-256 witness.',
|
||||
budget: 'L',
|
||||
active: true,
|
||||
status: 'available',
|
||||
tags: ['quantum', 'magnetometer', 'simulator', 'witness', 'wasm'],
|
||||
adr: 'ADR-089',
|
||||
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: '100–199' },
|
||||
sec: { label: 'Security & Safety', color: 'oklch(0.7 0.18 35)', range: '200–299' },
|
||||
bld: { label: 'Smart Building', color: 'oklch(0.78 0.12 195)', range: '300–399' },
|
||||
ret: { label: 'Retail & Hospitality', color: 'oklch(0.78 0.14 145)', range: '400–499' },
|
||||
ind: { label: 'Industrial', color: 'oklch(0.72 0.18 330)', range: '500–599' },
|
||||
sig: { label: 'Signal Processing', color: 'oklch(0.78 0.14 70)', range: '600–619' },
|
||||
lrn: { label: 'Online Learning', color: 'oklch(0.78 0.12 260)', range: '620–639' },
|
||||
spt: { label: 'Spatial / Graph', color: 'oklch(0.7 0.18 100)', range: '640–659' },
|
||||
tmp: { label: 'Temporal / Planning', color: 'oklch(0.7 0.16 50)', range: '660–679' },
|
||||
ais: { label: 'AI Safety', color: 'oklch(0.65 0.22 25)', range: '700–719' },
|
||||
qnt: { label: 'Quantum', color: 'oklch(0.72 0.18 290)', range: '720–739' },
|
||||
aut: { label: 'Autonomy', color: 'oklch(0.78 0.14 145)', range: '740–759' },
|
||||
exo: { label: 'Exotic / Research', color: 'oklch(0.72 0.18 330)', range: '650–699' },
|
||||
};
|
||||
|
||||
export interface AppActivation {
|
||||
id: string;
|
||||
/** Active in the current session. */
|
||||
active: boolean;
|
||||
/** Last activation timestamp. */
|
||||
lastActivatedAt?: number;
|
||||
/** Last event count seen (for the cards' counter). */
|
||||
eventCount?: number;
|
||||
}
|
||||
|
||||
export function defaultActivations(): AppActivation[] {
|
||||
return APPS.map((a) => ({ id: a.id, active: a.active === true, eventCount: 0 }));
|
||||
}
|
||||
|
||||
export function appsByCategory(): Record<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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user