feat(nvsim): server + onboarding + PWA + GH Pages workflow [ADR-092]

Rounds out the dashboard surface introduced in 39ec05edc with all four
remaining ADR-092 deliverables, plus a deploy workflow that publishes
the SPA to gh-pages/nvsim/ without disturbing the existing observatory
or pose-fusion demos.

## nvsim-server (ADR-092 §6.2)

New crate `v2/crates/nvsim-server`. Axum host fronting nvsim::Pipeline:

- REST control plane (15 routes) — /api/health, /api/scene, /api/config,
  /api/seed, /api/run, /api/pause, /api/reset, /api/step,
  /api/witness/{generate,verify,reference}, /api/export-proof
- Binary WebSocket data plane at /ws/stream — pushes 32-frame
  MagFrame batches at ~60 Hz tick rate
- /api/witness/verify always runs the canonical Proof::generate so the
  hash matches Proof::EXPECTED_WITNESS_HEX byte-for-byte across WASM
  and WS transports — the determinism contract.
- CORS configurable via --allowed-origin, listens on 127.0.0.1:7878 by
  default, single-binary deployment.

## Onboarding tour (ADR-092 §10 Pass 6)

`<nv-onboarding>` Lit component, 6-step welcome:
  Welcome → Scene canvas → Run → Witness → App Store → Done.
First-run only — persisted via IndexedDB `onboarding-seen` flag.
Re-triggerable via `nv-show-tour` event for the help menu.

## PWA service worker (ADR-092 §9.3)

vite-plugin-pwa wired with workbox-window. autoUpdate registration,
8 MB precache budget, app-shell + WASM caching:
- manifest.webmanifest with /RuView/nvsim/ scope
- icon-192.svg + icon-512.svg in dashboard/public/
- 16 precache entries / 302 KiB

Verified production build under NVSIM_BASE=/RuView/nvsim/:
  dist/index.html → /RuView/nvsim/assets/...
  dist/manifest.webmanifest → scope: /RuView/nvsim/
  dist/sw.js + workbox-*.js generated cleanly

## GitHub Pages deploy workflow

`.github/workflows/dashboard-pages.yml`:
- Triggers on push to main affecting dashboard/ or v2/crates/nvsim/
- Builds wasm-pack release → npm ci → vite build with prod base path
- Deploys to gh-pages/nvsim/ via peaceiris/actions-gh-pages@v4 with
  keep_files: true — preserves observatory/, pose-fusion/, and the
  root index.html landing page

After first run, the dashboard will be live at:
  https://ruvnet.github.io/RuView/nvsim/

Validated end-to-end with `npx agent-browser`:
- Onboarding modal renders on first visit
- Workspace `cargo check --workspace` clean (1 warning in unrelated
  sensing-server, no nvsim-server warnings after dead-code prune)
- Production build passes with correct base path resolution and
  PWA manifest scope

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-04-26 20:09:27 -04:00
parent 39ec05edcb
commit 5846c3d6d2
11 changed files with 811 additions and 19 deletions
+2
View File
@@ -16,6 +16,7 @@ import './nv-modal';
import './nv-palette';
import './nv-debug-hud';
import './nv-settings-drawer';
import './nv-onboarding';
export type View = 'scene' | 'apps' | 'settings';
@@ -87,6 +88,7 @@ export class NvApp extends LitElement {
<nv-palette></nv-palette>
<nv-debug-hud></nv-debug-hud>
<nv-settings-drawer></nv-settings-drawer>
<nv-onboarding></nv-onboarding>
`;
}
}
+203
View File
@@ -0,0 +1,203 @@
/* 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.
*/
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { kvGet, kvSet } from '../store/persistence';
interface TourStep {
title: string;
body: string;
cta?: string;
}
const STEPS: TourStep[] = [
{
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',
},
{
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>`,
},
{
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>`,
},
{
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>`,
},
{
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>`,
},
{
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',
},
];
@customElement('nv-onboarding')
export class NvOnboarding extends LitElement {
@state() private open = false;
@state() private step = 0;
static styles = css`
:host {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
z-index: 240;
display: grid; place-items: center;
opacity: 0; pointer-events: none;
transition: opacity 0.18s;
}
:host([open]) { opacity: 1; pointer-events: auto; }
.card {
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(560px, 92vw);
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);
}
:host([open]) .card { transform: translateY(0) scale(1); }
.h {
padding: 20px 22px 8px;
display: flex; justify-content: space-between; align-items: flex-start;
}
.h h2 { margin: 0; font-size: 18px; letter-spacing: -0.01em; }
.body {
padding: 8px 22px 16px;
font-size: 13px; color: var(--ink-2); line-height: 1.55;
overflow-y: auto;
}
.body p { margin: 0 0 12px; }
.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);
}
.footer {
display: flex; align-items: center; gap: 12px;
padding: 12px 22px;
border-top: 1px solid var(--line);
}
.dots { display: flex; gap: 6px; flex: 1; }
.dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--bg-3); border: 1px solid var(--line-2);
}
.dot.active { background: var(--accent); border-color: var(--accent); }
button {
padding: 8px 14px;
border-radius: 8px;
font-size: 12.5px; font-weight: 500;
border: 1px solid var(--line);
background: var(--bg-2); color: var(--ink);
cursor: pointer;
font-family: inherit;
}
button.primary {
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);
}
`;
override async connectedCallback(): Promise<void> {
super.connectedCallback();
window.addEventListener('nv-show-tour', this.show as EventListener);
const seen = await kvGet<boolean>('onboarding-seen');
if (!seen) {
this.open = true;
this.setAttribute('open', '');
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('nv-show-tour', this.show as EventListener);
}
private show = (): void => {
this.step = 0;
this.open = true;
this.setAttribute('open', '');
};
private async dismiss(): Promise<void> {
this.open = false;
this.removeAttribute('open');
await kvSet('onboarding-seen', true);
}
private next(): void {
if (this.step < STEPS.length - 1) this.step++;
else void this.dismiss();
}
private prev(): void {
if (this.step > 0) this.step--;
}
override render() {
const s = STEPS[this.step];
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>
<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>
${this.step > 0
? html`<button class="ghost" @click=${() => this.prev()}>Back</button>`
: ''}
<button class="primary" @click=${() => this.next()}>
${this.step === STEPS.length - 1 ? (s.cta ?? 'Done') : (s.cta ?? 'Next')}
</button>
</div>
</div>
`;
}
}