From 18c09d3305d4bbace46a7abc491a40143cf609a8 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 26 Apr 2026 22:08:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20iter=20G+H+I=20+=20P0.10=20?= =?UTF-8?q?=E2=80=94=20modal=20forms,=20a11y=20pass,=20drag=20persistence,?= =?UTF-8?q?=20REPL=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes ADR-093 P0.10, P1.2, P1.6, P1.7, P2.1, P2.2, P2.3, P2.5. ## Iter G — modal contents (P1.6) - nv-palette "New scene…" now opens a 5-field form (name, dipole moment, heart→sensor distance, ferrous toggle, 60 Hz mains toggle). On Apply: builds a real Scene JSON and pushes via client.loadScene(). - nv-palette "Export proof bundle…" now calls client.exportProofBundle() and triggers a real blob download with a timestamp filename. ## Iter H — a11y pass (P2.1, P2.2, P2.3, P2.5) - Skip-to-main-content link at top of nv-app (focus-visible only). -
wraps the central area; tabindex="-1" so the skip link can land focus there. - nv-rail wraps its 5 view buttons in
diff --git a/dashboard/src/components/nv-console.ts b/dashboard/src/components/nv-console.ts index a2967ad7..1888ece7 100644 --- a/dashboard/src/components/nv-console.ts +++ b/dashboard/src/components/nv-console.ts @@ -5,13 +5,12 @@ import { effect } from '@preact/signals-core'; import { consoleLines, consoleFilter, consolePaused, pushLog, getClient, seed, theme, expectedWitness, witnessHex, witnessVerified, - running, + running, replHistory, pushReplHistory, } from '../store/appStore'; @customElement('nv-console') export class NvConsole extends LitElement { @query('#console-input') private inputEl!: HTMLInputElement; - private history: string[] = []; private hIdx = -1; static styles = css` @@ -121,7 +120,8 @@ export class NvConsole extends LitElement { line = line.trim(); if (!line) return; pushLog('info', `nvsim> ${line}`); - this.history.push(line); this.hIdx = this.history.length; + pushReplHistory(line); + this.hIdx = replHistory.value.length; const [cmd, ...args] = line.split(/\s+/); const arg = args.join(' '); const c = getClient(); @@ -207,15 +207,17 @@ export class NvConsole extends LitElement { private onKey = (e: KeyboardEvent): void => { if (e.key === 'Enter') { void this.exec(this.inputEl.value); this.inputEl.value = ''; } else if (e.key === 'ArrowUp') { - if (this.history.length) { + const h = replHistory.value; + if (h.length) { this.hIdx = Math.max(0, this.hIdx - 1); - this.inputEl.value = this.history[this.hIdx] ?? ''; + this.inputEl.value = h[this.hIdx] ?? ''; e.preventDefault(); } } else if (e.key === 'ArrowDown') { - if (this.history.length) { - this.hIdx = Math.min(this.history.length, this.hIdx + 1); - this.inputEl.value = this.history[this.hIdx] ?? ''; + const h = replHistory.value; + if (h.length) { + this.hIdx = Math.min(h.length, this.hIdx + 1); + this.inputEl.value = h[this.hIdx] ?? ''; e.preventDefault(); } } @@ -241,7 +243,7 @@ export class NvConsole extends LitElement { -
+
${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` - -
+
- `; } 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; `
` recommended. | Add. | -| **P2.3** | Modal focus trap not implemented — Tab leaks to background | Add a small focus-trap to `nv-modal` and `nv-onboarding`. | -| **P2.4** | Color contrast on `.ink-3` light theme borderline for AA | Tweak palette. | -| **P2.5** | No skip-to-main-content link | Add. | +| **P2.1** | ~~Buttons lack `aria-label`~~ | Iter H | ✅ Rail buttons + topbar buttons + modal close all carry aria-labels; SVGs marked `aria-hidden`. | +| **P2.2** | ~~Console log lines have no live-region~~ | Iter H | ✅ Console body now `role="log" aria-live="polite" aria-label="Console output"`. | +| **P2.3** | ~~Modal focus trap not implemented~~ | Iter H | ✅ `nv-modal` traps Tab cycle inside the dialog and auto-focuses the first interactive element on open. | +| **P2.4** | Color contrast on `.ink-3` light theme borderline for AA | Tweak palette. *(Deferred — needs a color-system pass.)* | +| **P2.5** | ~~No skip-to-main-content link~~ | Iter H | ✅ `