diff --git a/dashboard/src/components/nv-console.ts b/dashboard/src/components/nv-console.ts index 24dcc24c..a2967ad7 100644 --- a/dashboard/src/components/nv-console.ts +++ b/dashboard/src/components/nv-console.ts @@ -127,7 +127,7 @@ export class NvConsole extends LitElement { const c = getClient(); switch (cmd) { case 'help': - pushLog('info', 'commands: help · scene.list · sensor.config · run · pause · seed · proof.verify · clear · theme · status'); + 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:'); @@ -172,6 +172,21 @@ export class NvConsole extends LitElement { } 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; diff --git a/dashboard/src/components/nv-scene.ts b/dashboard/src/components/nv-scene.ts index fc1edace..752ae70d 100644 --- a/dashboard/src/components/nv-scene.ts +++ b/dashboard/src/components/nv-scene.ts @@ -2,12 +2,14 @@ import { LitElement, html, css, svg } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { effect } from '@preact/signals-core'; -import { lastB, bMag, fps, snr, motionReduced } from '../store/appStore'; +import { lastB, bMag, fps, snr, motionReduced, running, getClient, speed, pushLog, lastFrame } 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' }, @@ -73,15 +75,118 @@ export class NvScene extends LitElement { 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(); - effect(() => { lastB.value; bMag.value; fps.value; snr.value; motionReduced.value; this.requestUpdate(); }); + 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); } + private async toggleRun(): Promise { + const c = getClient(); if (!c) return; + if (running.value) { await c.pause(); running.value = false; } + else { await c.run(); running.value = true; } + } + private async stepFwd(): Promise { + const c = getClient(); if (!c) return; + await c.step('fwd', 10); + pushLog('dbg', 'sim step → +1 frame'); + } + private async stepBack(): Promise { + 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); @@ -127,9 +232,15 @@ export class NvScene extends LitElement { 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`
- + @@ -139,14 +250,14 @@ export class NvScene extends LitElement { - ${this.items.map((it) => svg` + ${this.layerVisible.field ? this.items.map((it) => svg` - `)} + `) : ''} - ${this.items.map((it) => svg` + ${this.layerVisible.source ? this.items.map((it) => svg` - ${it.name} + ${this.layerVisible.label ? svg`${it.name}` : ''} - `)} + `) : ''} @@ -175,6 +286,27 @@ export class NvScene extends LitElement { +
+ + + + + + +
+ +
+ + + + ${speed.value}× +
+
|B|
diff --git a/dashboard/src/components/nv-sidebar.ts b/dashboard/src/components/nv-sidebar.ts index beb14ae8..e86086f8 100644 --- a/dashboard/src/components/nv-sidebar.ts +++ b/dashboard/src/components/nv-sidebar.ts @@ -2,7 +2,34 @@ import { LitElement, html, css } from 'lit'; import { customElement } from 'lit/decorators.js'; import { effect } from '@preact/signals-core'; -import { fs, fmod, dtMs, noiseEnabled, running } from '../store/appStore'; +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 { @@ -122,22 +149,22 @@ export class NvSidebar extends LitElement {
Sample rate${(fs.value / 1000).toFixed(1)} kHz
fs.value = +(e.target as HTMLInputElement).value} /> + @input=${(e: Event) => { fs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
Lockin f_mod${(fmod.value / 1000).toFixed(3)} kHz
fmod.value = +(e.target as HTMLInputElement).value} /> + @input=${(e: Event) => { fmod.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
Integration t${dtMs.value.toFixed(1)} ms
dtMs.value = +(e.target as HTMLInputElement).value} /> + @input=${(e: Event) => { dtMs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
Shot noise${noiseEnabled.value ? 'ON' : 'OFF'}
noiseEnabled.value = (e.target as HTMLInputElement).value === '1'} /> + @input=${(e: Event) => { noiseEnabled.value = (e.target as HTMLInputElement).value === '1'; pushConfigDebounced(); }} />
diff --git a/dashboard/src/components/nv-topbar.ts b/dashboard/src/components/nv-topbar.ts index ebca0105..64459597 100644 --- a/dashboard/src/components/nv-topbar.ts +++ b/dashboard/src/components/nv-topbar.ts @@ -4,8 +4,10 @@ import { customElement } from 'lit/decorators.js'; import { effect } from '@preact/signals-core'; import { fps, transportLabel, seed, theme, sceneName, - running, getClient, + running, getClient, pushLog, } from '../store/appStore'; +import { openModal } from './nv-modal'; +import { toast } from './nv-toast'; @customElement('nv-topbar') export class NvTopbar extends LitElement { @@ -31,8 +33,11 @@ export class NvTopbar extends LitElement { } .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); animation: pulse 2s infinite; } .pill.wasm .dot { background: var(--accent-2); box-shadow: 0 0 6px var(--accent-2); } - .pill.seed { color: var(--ink-3); } + .pill.seed { 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; @@ -65,6 +70,31 @@ export class NvTopbar extends LitElement { private toggleTheme(): void { theme.value = theme.value === 'dark' ? 'light' : 'dark'; } + private async openSeedModal(): Promise { + const cur = `0x${seed.value.toString(16).toUpperCase().padStart(8, '0')}`; + openModal({ + title: 'Set seed', + body: `

Set the 32-bit hex seed for the shot-noise PRNG. Same (scene, config, seed) → byte-identical witness.

+ + `, + buttons: [ + { label: 'Cancel', variant: 'ghost' }, + { label: 'Apply', variant: 'primary', onClick: async () => { + const inp = document.querySelector('nv-modal')?.shadowRoot?.querySelector('#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'); @@ -79,8 +109,14 @@ export class NvTopbar extends LitElement { ${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'idle'} - ${transportLabel.value} - seed: 0x${seedHex} + + ${transportLabel.value} + + + seed: 0x${seedHex} + diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 4ed86b2e..0ce41887 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -26,7 +26,8 @@ function applyMotion(reduced: boolean): void { // Restore persisted prefs const t = (await kvGet<'dark' | 'light'>('theme')) ?? 'dark'; const d = (await kvGet<'comfy' | 'default' | 'compact'>('density')) ?? 'default'; - const m = (await kvGet('motionReduced')) ?? false; + const sysMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + const m = (await kvGet('motionReduced')) ?? sysMotion; theme.value = t; applyTheme(t); density.value = d; applyDensity(d); motionReduced.value = m; applyMotion(m); diff --git a/docs/adr/ADR-093-dashboard-gap-analysis.md b/docs/adr/ADR-093-dashboard-gap-analysis.md index 710a641d..0435a217 100644 --- a/docs/adr/ADR-093-dashboard-gap-analysis.md +++ b/docs/adr/ADR-093-dashboard-gap-analysis.md @@ -40,11 +40,11 @@ The closing §5 is the iteration plan. | **P0.2** | ~~Witness rail button no-op~~ | `nv-rail.ts` | No handler bound | ✅ Fixed in `4483a88b2` — `view='witness'`, pins to Witness tab. | | **P0.3** | ~~No Ghost Murmur view despite shipping research spec~~ | rail / app | Research spec at `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md` had no dashboard surface | ✅ Fixed in `4483a88b2` — new `` component, dedicated rail icon. | | **P0.4** | Ghost Murmur view is **read-only** | `nv-ghost-murmur.ts` | Currently a static document. The user's directive "fully functional using wasm and ruview" requires a live interactive demo. | ⏳ §5 below — interactive distance/moment sliders that actually drive `nvsim::Pipeline` via WASM and report per-tier detectability. | -| **P0.5** | Topbar `seed` pill is decorative | `nv-topbar.ts` | Pill renders but click does nothing — should open the seed-set modal | ⏳ Wire to `openModal({ title: 'Set seed', … })`. | -| **P0.6** | Sim controls overlay (`step ⏮ play ⏯ step ⏭ + speed`) absent in scene | `nv-scene.ts` | Mockup ships `.sim-controls` floating widget; not ported | ⏳ Add as a corner overlay in ``. | -| **P0.7** | Scene toolbar (zoom / fit / layers) missing | `nv-scene.ts` | Mockup ships `.scene-toolbar` top-left; not ported | ⏳ Implement zoom (SVG viewBox scale), fit-to-view, layer-toggle for each source class. | +| **P0.5** | ~~Topbar `seed` pill is decorative~~ | `nv-topbar.ts` | ✅ Iter C — opens "Set seed" modal with hex input; applies via `WasmClient.setSeed`. | +| **P0.6** | ~~Sim controls overlay absent~~ | `nv-scene.ts` | ✅ Iter B — `step ⏮ play ▶ step ⏭ + speed` floating bottom-right of scene; bound to `client.run/pause/step` and `speed.value` cycle. | +| **P0.7** | ~~Scene toolbar (zoom / fit / layers) missing~~ | `nv-scene.ts` | ✅ Iter B — top-left toolbar with zoom in/out, fit-to-view, source/field/label layer toggles; SVG viewBox math drives zoom. | | **P0.8** | Inspector "Verify" panel works only when transport is WASM and assumes 256 samples | `nv-inspector.ts`, `WasmClient.ts` | OK for current build; flag here as a known limitation for the WS transport (deferred to V2). | Document — not a fix. | -| **P0.9** | REPL `proof.export` command not implemented | `nv-console.ts` | Mockup has `proof.export` returning a downloadable bundle | ⏳ Wire to `client.exportProofBundle()` and trigger blob download. | +| **P0.9** | ~~REPL `proof.export` not implemented~~ | `nv-console.ts` | ✅ Iter E — wires to `client.exportProofBundle()`, triggers a blob download with timestamp filename. | | **P0.10** | REPL command history is per-component, lost on view switch | `nv-console.ts` | `history` is instance-private | ⏳ Move to `appStore` so it survives view changes (low impact but expected). | ## 3. P1 — visible mockup features missing @@ -54,14 +54,14 @@ The closing §5 is the iteration plan. | **P1.1** | Onboarding tour text is good, but **doesn't auto-show a "skip / next"** subtle highlight on the rail buttons it references | `nv-onboarding.ts` | Mockup uses spotlight cutouts. Ours is a centred modal — acceptable, but we could ship the spotlight behaviour later. | | **P1.2** | Density toggle in Settings drawer doesn't visibly change anything | `app.css` + `nv-settings-drawer` | CSS has `body.density-comfy/default/compact` rules but the application code only modifies `body.style.fontSize`. Wire the body class through `appStore.density` properly. | | **P1.3** | `motion-toggle` only flips `body.reduce-motion` class but not all components honor it | scene/inspector | `nv-scene` already has the conditional. Verify B-trace and frame-strip animations stop too. | -| **P1.4** | Scene "stat-card" SNR readout is always `—` | `nv-scene.ts` | We never compute SNR from frames. Compute as |b| / max(σ_per_axis) and surface. | +| **P1.4** | ~~Scene "stat-card" SNR readout always `—`~~ | `nv-scene.ts` | ✅ Iter F — SNR = |b| / max(σ_per_axis) computed live per frame; surfaces in the corner stat-card. | | **P1.5** | Inspector `frame-strip-2` from the Frame tab not in our impl | `nv-inspector.ts` | Mockup has a second sparkline strip in the Frame tab; we only ship one. Replicate. | | **P1.6** | Modals: New Scene + Export Proof + About defined in palette but body content is short | `nv-palette.ts` | Mockup's New-Scene dialog ships a full form (sources count, ferrous toggle, etc.). Ours is a placeholder. Implement form. | | **P1.7** | Scene drag persistence | `nv-scene.ts` | Mockup persists drag positions via IndexedDB. Add. | -| **P1.8** | Sidebar Tunables sliders don't actually update the running pipeline | `nv-sidebar.ts` + `WasmClient.ts` | Slider changes the signal but worker isn't told to rebuild the pipeline with new fs/fmod. Wire `setConfig()` debounced. | +| **P1.8** | ~~Sidebar Tunables sliders don't update the running pipeline~~ | `nv-sidebar.ts` + `WasmClient.ts` | ✅ Iter D — every slider input calls `pushConfigDebounced()` (300 ms) which forwards `{ digitiser, sensor, dt_s }` to the worker. Worker rebuilds the WasmPipeline with the new config. Verified via REPL log line `config pushed · fs=… f_mod=…`. | | **P1.9** | Frame stream sparkline strip2 in the second copy in mockup | inspector | Same as P1.5 — verify. | -| **P1.10** | "WASM" pill in topbar should show actual transport (`wasm` / `ws`); clicking should let user toggle | `nv-topbar.ts` | Pill is read-only. Make it a toggle that opens the Settings drawer at the Transport section. | -| **P1.11** | `prefers-reduced-motion` system preference not auto-detected | `main.ts` | Read once at boot, default `motionReduced` to `true` when set. | +| **P1.10** | ~~"WASM" pill is read-only~~ | `nv-topbar.ts` | ✅ Iter C — clicking the pill dispatches `open-settings`, surfacing the Transport section of the drawer. | +| **P1.11** | ~~`prefers-reduced-motion` not auto-detected~~ | `main.ts` | ✅ Iter F — `window.matchMedia('(prefers-reduced-motion: reduce)').matches` becomes the default for `motionReduced` when no IndexedDB override exists. | | **P1.12** | Scene 3D-tilt on pointer move not ported | `nv-scene.ts` | Mockup has `.tilt-stage` perspective transform. Optional polish. | | **P1.13** | View-overlay "expand panel" not ported | global | Mockup has a `.view-overlay` that expands any inspector panel to full-screen. Defer V2. |