diff --git a/dashboard/src/components/nv-app.ts b/dashboard/src/components/nv-app.ts
index bdfb8629..a1362f78 100644
--- a/dashboard/src/components/nv-app.ts
+++ b/dashboard/src/components/nv-app.ts
@@ -32,6 +32,21 @@ export class NvApp extends LitElement {
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;
@@ -74,17 +89,21 @@ export class NvApp extends LitElement {
override render() {
return html`
+ { e.preventDefault(); const sr = this.shadowRoot; sr?.querySelector('.main')?.focus(); }}>
+ Skip to main content
+
+
${visible.map((l) => {
const ts = new Date(l.ts);
const tsStr = `${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`;
diff --git a/dashboard/src/components/nv-modal.ts b/dashboard/src/components/nv-modal.ts
index 3306a932..23b4c657 100644
--- a/dashboard/src/components/nv-modal.ts
+++ b/dashboard/src/components/nv-modal.ts
@@ -91,8 +91,38 @@ export class NvModal extends LitElement {
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
('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('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();
};
diff --git a/dashboard/src/components/nv-palette.ts b/dashboard/src/components/nv-palette.ts
index 86657b15..3a142e49 100644
--- a/dashboard/src/components/nv-palette.ts
+++ b/dashboard/src/components/nv-palette.ts
@@ -67,6 +67,70 @@ export class NvPalette extends LitElement {
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: `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).
+
+
+
+
+
+
+
+
+
+ `,
+ 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('#ns-name')?.value ?? 'custom').trim();
+ const m = parseFloat(root.querySelector('#ns-moment')?.value ?? '1e-6');
+ const d = parseFloat(root.querySelector('#ns-distance')?.value ?? '0.5');
+ const ferr = root.querySelector('#ns-ferrous')?.value === '1';
+ const mains = root.querySelector('#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 ${name} 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: 'Clears the frame stream and rewinds t to 0.
',
diff --git a/dashboard/src/components/nv-rail.ts b/dashboard/src/components/nv-rail.ts
index d947c7b3..c9b00574 100644
--- a/dashboard/src/components/nv-rail.ts
+++ b/dashboard/src/components/nv-rail.ts
@@ -61,36 +61,49 @@ export class NvRail extends LitElement {
override render() {
return html`
- NV
-
+
-
`;
}
diff --git a/dashboard/src/components/nv-scene.ts b/dashboard/src/components/nv-scene.ts
index 752ae70d..f6496c56 100644
--- a/dashboard/src/components/nv-scene.ts
+++ b/dashboard/src/components/nv-scene.ts
@@ -2,7 +2,7 @@
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 } from '../store/appStore';
+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; }
@@ -142,6 +142,13 @@ export class NvScene extends LitElement {
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;
@@ -217,7 +224,13 @@ export class NvScene extends LitElement {
);
};
- private onPointerUp = (): void => { this.dragging = null; };
+ 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();
diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts
index 0ce41887..f9020547 100644
--- a/dashboard/src/main.ts
+++ b/dashboard/src/main.ts
@@ -8,6 +8,7 @@ import {
setClient, transport, theme, density, motionReduced,
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
+ replHistory, scenePositions, type SceneItemPos,
} from './store/appStore';
import { kvGet, kvSet } from './store/persistence';
@@ -37,6 +38,14 @@ function applyMotion(reduced: boolean): void {
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
('repl-history');
+ if (histSaved && Array.isArray(histSaved)) replHistory.value = histSaved;
+ effect(() => { void kvSet('repl-history', replHistory.value); });
+ const positionsSaved = await kvGet('scene-positions');
+ if (positionsSaved && Array.isArray(positionsSaved)) scenePositions.value = positionsSaved;
+ effect(() => { void kvSet('scene-positions', scenePositions.value); });
+
// Boot WASM client
const client = new WasmClient();
setClient(client);
diff --git a/dashboard/src/store/appStore.ts b/dashboard/src/store/appStore.ts
index c8e4d52c..9616add9 100644
--- a/dashboard/src/store/appStore.ts
+++ b/dashboard/src/store/appStore.ts
@@ -55,6 +55,19 @@ export const sceneJson = signal('');
export const consolePaused = signal(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([]);
+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([]);
+
export const transportLabel = computed(() =>
transport.value === 'wasm' ? 'wasm' : 'ws',
);
diff --git a/docs/adr/ADR-093-dashboard-gap-analysis.md b/docs/adr/ADR-093-dashboard-gap-analysis.md
index 0435a217..578b7a34 100644
--- a/docs/adr/ADR-093-dashboard-gap-analysis.md
+++ b/docs/adr/ADR-093-dashboard-gap-analysis.md
@@ -45,19 +45,19 @@ The closing §5 is the iteration plan.
| **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` 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). |
+| **P0.10** | ~~REPL command history is per-component~~ | `nv-console.ts` | ✅ Iter G — moved to `appStore.replHistory` signal, persisted via IndexedDB key `repl-history`. |
## 3. P1 — visible mockup features missing
| # | Gap | Location | Notes |
|---|---|---|---|
| **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.2** | ~~Density toggle didn't visibly change anything~~ | `main.ts` + `app.css` | ✅ Iter I — `applyDensity()` already swapped body class; verified during this iter the CSS rules now actually take effect (15/14/13 px font scale on `body.density-{comfy,default,compact}`). |
| **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 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.6** | ~~Modals body content was short~~ | `nv-palette.ts` | ✅ Iter G — New Scene modal now ships a 5-field form (name, dipole moment, distance, ferrous toggle, mains toggle) and emits real Scene JSON pushed to `client.loadScene()`. Export Proof rewritten to call `exportProofBundle` + trigger blob download. |
+| **P1.7** | ~~Scene drag positions don't persist~~ | `nv-scene.ts` | ✅ Iter I — `scenePositions` signal in appStore, persisted via IndexedDB on each pointer-up. Restored at component connect. |
| **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 is read-only~~ | `nv-topbar.ts` | ✅ Iter C — clicking the pill dispatches `open-settings`, surfacing the Transport section of the drawer. |
@@ -69,11 +69,11 @@ The closing §5 is the iteration plan.
| # | Gap | Notes |
|---|---|---|
-| **P2.1** | Many buttons lack `aria-label` (the SVG icons are not screen-reader-friendly) | Add. |
-| **P2.2** | Console log lines are text-only; `