feat(dashboard): UX usability pass — help center, 10-step welcome tour, panel descriptions

Addresses user feedback: "make the UI generally easier to use with more
descriptions, help, settings, and guidance."

## New: nv-help — comprehensive help center

Single dialog with 5 tabs:
- 🚀 Quickstart — 7 numbered steps covering Run/B-trace/Verify/Drag/Tunables/Ghost Murmur/App Store
- 📖 Glossary — 14 jargon terms (NV-diamond, CW-ODMR, MagFrame, Witness,
  Determinism gate, Lock-in demod, Shot-noise floor, Biot-Savart,
  Multistatic fusion, Scene, Tunables, Transport, App Store, Ghost Murmur),
  each with category badge (physics/rust/ui) and a search box
- ? FAQ — 7 frequently-asked questions with answers about determinism,
  recovered vs predicted |B|, custom scenes, data privacy, witness
  mismatch, Inspector vs right-rail, App Store rationale
- ⌨ Shortcuts — full keymap (12 chords)
- ℹ About — what nvsim is, the Apache-2.0/MIT license, the determinism
  commitment, GitHub link

Triggers: ? button in topbar, ? key from anywhere, Settings → Help.

## nv-onboarding — expanded from 6 to 10 steps

Each step now has an icon, body, and an optional 💡 hint. Steps walk
through: Welcome → Scene → Run → Inspector → Witness → Tunables →
Ghost Murmur → App Store → Console+REPL → Done. Each step has a
"Step X of 10" label and improved progress dots (active/done/empty).

## Sidebar panel descriptions

Each panel (Scene, NV sensor, Tunables, Pipeline) gets a 1-2 sentence
explainer paragraph. NV sensor panel includes a "What's NV?" link
that jumps to the Glossary section in nv-help. Each Tunables slider
has a `title` tooltip explaining what it controls.

## Settings drawer rewritten with explanations

Every toggle now has a `desc` paragraph explaining what it changes,
when to use it, and any cross-references (ADRs, defaults). Three new
rows added:
- Open help center
- Replay welcome tour
- Reset all preferences (with confirm + IndexedDB wipe + reload)
About row links into nv-help's About section.

## Inspector empty states

Both Signal and Frame tabs now show a friendly empty state when no
frames have arrived: "No frames yet. Press ▶ Run in the topbar (or
hit Space) to start the live B-vector trace." Witness already had
its own empty state.

## A11y additions

- Topbar `?` button has aria-label="Open help"
- Theme button has aria-label="Toggle theme"
- Settings toggles (motion, auto-update) have role="switch" + aria-checked
- Sidebar slider inputs have aria-label
- Help center modal: role=dialog, role=tablist with role=tab buttons
  + aria-selected, role=tabpanel for body

Validated end-to-end against https://ruvnet.github.io/RuView/nvsim/:
- Welcome modal opens on first visit, "Step 1 of 10", 10 dots
- ? button opens help center, 5 nav sections, Quickstart loads first
- Glossary tab shows 14 term entries
- Sidebar panel intros render correctly
- Inspector shows "No frames yet" empty state when idle

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-04-27 10:40:02 -04:00
parent eed5feeab2
commit 21ad10e8d8
7 changed files with 879 additions and 99 deletions

View File

@ -18,6 +18,7 @@ import './nv-debug-hud';
import './nv-settings-drawer';
import './nv-onboarding';
import './nv-ghost-murmur';
import './nv-help';
export type View = 'scene' | 'apps' | 'inspector' | 'witness' | 'ghost-murmur';
@ -120,6 +121,7 @@ export class NvApp extends LitElement {
<nv-debug-hud></nv-debug-hud>
<nv-settings-drawer></nv-settings-drawer>
<nv-onboarding></nv-onboarding>
<nv-help></nv-help>
`;
}
}

View File

@ -0,0 +1,450 @@
/* 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('keydown', this.onKey);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('nv-show-help', this.show as EventListener);
window.removeEventListener('keydown', this.onKey);
}
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>
${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 } }));
}

View File

@ -237,8 +237,17 @@ export class NvInspector extends LitElement {
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">
@ -286,6 +295,13 @@ export class NvInspector extends LitElement {
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">

View File

@ -1,6 +1,8 @@
/* First-run welcome tour. 5 steps walking the user through the
* dashboard's main concepts. Persists `seen` flag in IndexedDB so it
* only shows the first time. ADR-092 §10 Pass 6.
/* 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';
@ -8,60 +10,165 @@ 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;
cta?: 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><b>nvsim</b> is an open-source, deterministic forward simulator for
nitrogen-vacancy diamond magnetometry a real Rust crate compiled to
WASM and running in your browser, right now.</p>
<p>This 30-second tour highlights the four panels you'll use most.</p>`,
cta: 'Start tour',
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 →' },
},
{
title: '1. Scene canvas',
body: `<p>The middle panel shows your <b>magnetic scene</b> — sources you can
drag (rebar, heart proxy, mains hum, ferrous door) and a single NV-diamond
sensor in the centre. Field lines from each source connect to the sensor
and animate while the pipeline runs.</p>
<p>Click <code>2</code> on your keyboard any time to jump to the Frame inspector.</p>`,
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.',
},
{
title: '2. Run the pipeline',
body: `<p>Click the <b>▶ Run</b> button (top-right) to start streaming
<code>MagFrame</code> records at the digitiser's sample rate. The B-vector
trace and Frame stream sparkline update live, and the FPS pill in the
topbar shows the simulator's throughput in kHz.</p>
<p><kbd>Space</kbd> toggles run/pause from anywhere.</p>`,
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>`,
},
{
title: '3. Witness panel',
body: `<p>The <b>Witness</b> tab is the heart of nvsim's determinism contract.
Click <b>Verify</b> and the pipeline re-derives the SHA-256 over a 256-frame
reference run and asserts it matches the constant pinned in the Rust crate.</p>
<p>Same input same hash byte-for-byte across browsers, OSes, transports.
If the hash drifts, your build is non-canonical.</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>`,
},
{
title: '4. App Store',
body: `<p>The grid icon on the left rail opens the <b>App Store</b> — every
hot-loadable WASM edge module RuView ships, plus the simulators. 66 apps
across 13 categories: medical, security, building, retail, industrial,
signal, learning, autonomy, and more.</p>
<p>Toggle any card to mark it active in this session; the WS transport
will push the activation set to a connected ESP32 mesh.</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> (1100 kHz) digitiser frame rate</li>
<li><b>Lock-in f_mod</b> (0.15 kHz) microwave modulation freq</li>
<li><b>Integration t</b> (0.110 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>Press <kbd>⌘K</kbd> (or <kbd>Ctrl K</kbd>) any time for the command
palette, <kbd>?</kbd> for the full shortcuts list, or just start clicking.</p>
<p>Source on GitHub:
<code>github.com/ruvnet/RuView</code> · ADR-089, ADR-092 · MIT/Apache-2.0.</p>`,
cta: 'Get started',
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 →' },
},
];
@ -86,60 +193,132 @@ export class NvOnboarding extends LitElement {
border: 1px solid var(--line-2);
border-radius: var(--radius);
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
width: min(560px, 92vw);
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: 20px 22px 8px;
display: flex; justify-content: space-between; align-items: flex-start;
padding: 22px 26px 12px;
display: flex; align-items: flex-start; gap: 14px;
}
.h h2 { margin: 0; font-size: 18px; letter-spacing: -0.01em; }
.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: 8px 22px 16px;
font-size: 13px; color: var(--ink-2); line-height: 1.55;
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;
color: var(--accent);
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: 12px;
padding: 12px 22px;
display: flex; align-items: center; gap: 14px;
padding: 14px 22px;
border-top: 1px solid var(--line);
background: var(--bg-1);
}
.dots { display: flex; gap: 6px; flex: 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);
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); }
button {
padding: 8px 14px;
.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: 12.5px; font-weight: 500;
border: 1px solid var(--line);
background: var(--bg-2); color: var(--ink);
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);
background: var(--accent);
border-color: var(--accent);
color: #1a0f00;
}
button.ghost { background: transparent; }
.skip {
width: 28px; height: 28px;
background: transparent; border: 1px solid var(--line);
border-radius: 6px; color: var(--ink-2);
}
button.primary:hover { filter: brightness(1.08); }
`;
override async connectedCallback(): Promise<void> {
@ -169,6 +348,8 @@ export class NvOnboarding extends LitElement {
}
private next(): void {
const s = STEPS[this.step];
s.cta?.run?.();
if (this.step < STEPS.length - 1) this.step++;
else void this.dismiss();
}
@ -179,22 +360,35 @@ export class NvOnboarding extends LitElement {
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">
<h2>${s.title}</h2>
<button class="skip" @click=${() => this.dismiss()} aria-label="Skip tour">×</button>
<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="body" .innerHTML=${s.body}></div>
<div class="footer">
<div class="dots">
${STEPS.map((_, i) => html`<div class="dot ${i === this.step ? 'active' : ''}"></div>`)}
<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.prev()}>Back</button>`
: html`<button class="ghost" @click=${() => this.dismiss()}>Skip</button>`}
<button class="primary" @click=${() => this.next()}>
${this.step === STEPS.length - 1 ? (s.cta ?? 'Done') : (s.cta ?? 'Next')}
${s.cta?.label ?? (isLast ? 'Done' : 'Next →')}
</button>
</div>
</div>

View File

@ -111,6 +111,15 @@ export class NvSettingsDrawer extends LitElement {
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>
@ -122,29 +131,38 @@ export class NvSettingsDrawer extends LitElement {
<div class="group">
<h4>Appearance</h4>
<div class="row">
<div><div class="lbl">Theme</div></div>
<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>
<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.</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>
<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">Disable rotating crystal & field-line animation.</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>
@ -152,9 +170,12 @@ export class NvSettingsDrawer extends LitElement {
<div class="group">
<h4>Pipeline</h4>
<div class="row">
<div><div class="lbl">Auto-rerun on edit</div>
<div class="desc">Restart pipeline when scene/config changes.</div></div>
<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>
@ -162,19 +183,78 @@ export class NvSettingsDrawer extends LitElement {
<div class="group">
<h4>Transport</h4>
<div class="row">
<div><div class="lbl">Mode</div></div>
<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>
<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>
<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>
`;
}

View File

@ -47,8 +47,19 @@ export class NvSidebar extends LitElement {
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: 10px;
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;
@ -113,6 +124,10 @@ export class NvSidebar extends LitElement {
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>
@ -137,39 +152,57 @@ export class NvSidebar extends LitElement {
<div class="panel">
<div class="panel-h">NV sensor <span class="count">COTS</span></div>
<div class="field-row"><span class="lbl">V</span><span class="val">1 mm³</span></div>
<div class="field-row"><span class="lbl">N</span><span class="val">1e12 NV</span></div>
<div class="field-row"><span class="lbl">C</span><span class="val">0.030</span></div>
<div class="field-row"><span class="lbl">T*</span><span class="val">200 ns</span></div>
<div class="field-row"><span class="lbl">δB</span><span class="val">1.18 pT/Hz</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="slider-row">
<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">
<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">
<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">
<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>

View File

@ -117,7 +117,12 @@ export class NvTopbar extends LitElement {
@click=${this.openSeedModal}>
seed: <b>0x${seedHex}</b>
</span>
<button class="ghost" id="theme-btn" title="Toggle theme" @click=${this.toggleTheme}>
<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>