feat(dashboard): iter G+H+I + P0.10 — modal forms, a11y pass, drag persistence, REPL history
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).
- <main id="main-content" role="main"> wraps the central area; tabindex="-1"
so the skip link can land focus there.
- nv-rail wraps its 5 view buttons in <nav role="navigation"
aria-label="Primary"> with aria-current="page" on the active button
and aria-label on every button. SVGs marked aria-hidden="true".
- nv-console body is now role="log" aria-live="polite"
aria-label="Console output".
- nv-modal auto-focuses first interactive element on open and traps
Tab cycling inside the dialog; nv-onboarding already had a dismiss
affordance covered.
## Iter I — drag persistence (P1.7) + density visual (P1.2)
- scenePositions signal in appStore + IndexedDB key 'scene-positions'.
- nv-scene restores drag positions at connect; persists on pointerup.
- Density visual (CSS body.density-{comfy,default,compact}) confirmed
active — was already wired but flagged as "doesn't change anything"
in P1.2; verified during this iter.
## P0.10 — REPL history persistence
- replHistory + pushReplHistory in appStore, persisted to IndexedDB
key 'repl-history'.
- nv-console arrow-up/down now read from the shared signal so command
history survives view switches and reloads.
Validated end-to-end with `npx agent-browser` on
https://ruvnet.github.io/RuView/nvsim/ — skip-link, main role, console
log role, nav role, aria-current="page", New Scene modal with 5 form
fields all confirmed live. Console errors: zero.
ADR-093 §2/§3/§4 updated to mark these items resolved.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
c9fbda12fd
commit
18c09d3305
|
|
@ -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`
|
||||
<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">
|
||||
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
|
||||
<nv-topbar></nv-topbar>
|
||||
<nv-sidebar></nv-sidebar>
|
||||
<div class="main">
|
||||
<main class="main" id="main-content" tabindex="-1" role="main" aria-label="Main view">
|
||||
${this.view === 'apps'
|
||||
? html`<nv-app-store></nv-app-store>`
|
||||
: this.view === 'ghost-murmur'
|
||||
? html`<nv-ghost-murmur></nv-ghost-murmur>`
|
||||
: html`<nv-scene></nv-scene>`}
|
||||
</div>
|
||||
</main>
|
||||
<nv-inspector
|
||||
.pinTab=${this.view === 'inspector' ? 'signal'
|
||||
: this.view === 'witness' ? 'witness' : null}>
|
||||
|
|
|
|||
|
|
@ -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', `<span style="color:var(--accent);">nvsim></span> ${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 {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<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')}`;
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: `<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>',
|
||||
|
|
|
|||
|
|
@ -61,36 +61,49 @@ export class NvRail extends LitElement {
|
|||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="logo">NV</div>
|
||||
<button class="btn ${this.view === 'scene' ? 'active' : ''}" data-id="scene-btn" title="Scene"
|
||||
<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 === '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"><path d="M12 2L3 7l9 5 9-5-9-5zm0 13l-9-5v6l9 5 9-5v-6l-9 5z"/></svg>
|
||||
<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"
|
||||
<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"><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>
|
||||
<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"
|
||||
<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"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
|
||||
<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"
|
||||
<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"><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>
|
||||
<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">
|
||||
<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"
|
||||
<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"><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>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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); });
|
||||
|
||||
// Boot WASM client
|
||||
const client = new WasmClient();
|
||||
setClient(client);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,19 @@ 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[]>([]);
|
||||
|
||||
export const transportLabel = computed<string>(() =>
|
||||
transport.value === 'wasm' ? 'wasm' : 'ws',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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; `<div role="log" aria-live="polite">` 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 | ✅ `<a class="skip-link" href="#main-content">` at top of `nv-app`, focus-visible only when keyboard-targeted. Main view wrapped in `<main id="main-content" role="main">`. |
|
||||
| **P2.6** | Keyboard navigation through scene draggable sources via arrow keys | Add. |
|
||||
| **P2.7** | Service worker doesn't have `clients.claim()` | Confirm. Ensures new SW activates on next nav. |
|
||||
| **P2.8** | PWA install prompt is silent | Add an install button (visible only when `beforeinstallprompt` fires). |
|
||||
|
|
|
|||
Loading…
Reference in New Issue