/* Inspector — tabbed: Signal / Frame / Witness. */ import { LitElement, html, css, svg, type PropertyValues } from 'lit'; import { customElement, state, property } from 'lit/decorators.js'; import { effect } from '@preact/signals-core'; import { traceX, traceY, traceZ, stripBars, lastFrame, witnessHex, expectedWitness, witnessVerified, getClient, pushLog, lastB, bMag, } from '../store/appStore'; type Tab = 'signal' | 'frame' | 'witness'; @customElement('nv-inspector') export class NvInspector extends LitElement { @state() private tab: Tab = 'signal'; /** When set by the parent, force the tab and pulse-highlight it. */ @property({ attribute: false }) pinTab: Tab | null = null; /** When `expanded`, the inspector renders as a full-screen view with bigger * charts and a wider Witness panel. Used when the rail Inspector/Witness * button is clicked — see ADR-093 P1.13. */ @property({ type: Boolean, reflect: true }) expanded = false; static styles = css` :host { display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); overflow: hidden; height: 100%; } :host([expanded]) { border-left: 0; background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%); } :host([expanded]) .tabs { padding: 0 24px; background: var(--bg-1); } :host([expanded]) .tab { padding: 16px 22px; font-size: 13.5px; flex: 0 0 auto; } :host([expanded]) .body { padding: 24px 28px; max-width: 1400px; width: 100%; margin: 0 auto; } :host([expanded]) .card { padding: 18px 20px; } :host([expanded]) .card-h .ttl { font-size: 14px; } :host([expanded]) svg { height: 220px; } :host([expanded]) .frame-strip { height: 48px; } :host([expanded]) table { font-size: 12.5px; } :host([expanded]) td { padding: 6px 0; } :host([expanded]) .hex { font-size: 12px; padding: 14px; line-height: 1.7; } :host([expanded]) .witness-box { font-size: 13px; padding: 14px 16px; line-height: 1.6; } :host([expanded]) .verify-btn { padding: 12px; font-size: 13px; } :host([expanded]) .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } :host([expanded]) .grid-2 > .card { margin-bottom: 0; } @media (max-width: 1024px) { :host([expanded]) .grid-2 { grid-template-columns: 1fr; } } .tabs { display: flex; border-bottom: 1px solid var(--line); } .tab { flex: 1; padding: 11px 8px; background: transparent; border: none; font-size: 11.5px; font-weight: 500; color: var(--ink-3); border-bottom: 2px solid transparent; cursor: pointer; transition: color 0.15s, border-color 0.15s; } .tab.active { color: var(--ink); border-bottom-color: var(--accent); } .tab:hover { color: var(--ink-2); } .body { padding: 14px; flex: 1; overflow-y: auto; } .card { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; margin-bottom: 12px; } .card-h { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .card-h .ttl { font-size: 12px; font-weight: 600; } .badge { font-family: var(--mono); font-size: 10px; padding: 2px 6px; background: oklch(0.78 0.14 195 / 0.12); color: var(--accent-2); border-radius: 4px; border: 1px solid oklch(0.78 0.14 195 / 0.3); } svg { width: 100%; height: 130px; } .frame-strip { height: 28px; display: flex; align-items: flex-end; gap: 1px; padding: 4px 0; } .bar { flex: 1; background: linear-gradient(to top, var(--accent-2), var(--accent)); border-radius: 1px; min-height: 2px; } table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; } td { padding: 4px 0; border-bottom: 1px solid var(--line); } td:first-child { color: var(--ink-3); } td:last-child { text-align: right; color: var(--ink); } .hex { background: var(--bg-3); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 10px; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); line-height: 1.6; overflow-x: auto; white-space: nowrap; } .hex .magic { color: var(--accent); font-weight: 600; } .witness-box { font-family: var(--mono); font-size: 11px; color: var(--ink-2); background: var(--bg-3); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; word-break: break-all; line-height: 1.5; } .verify-btn { margin-top: 10px; width: 100%; padding: 8px; border: 1px solid var(--line); background: var(--bg-3); color: var(--ink); border-radius: 8px; cursor: pointer; font-family: var(--mono); font-size: 12px; } .verify-btn:hover { border-color: var(--accent); } .verify-btn.ok { border-color: var(--ok); color: var(--ok); } .verify-btn.fail { border-color: var(--bad); color: var(--bad); } `; override connectedCallback(): void { super.connectedCallback(); effect(() => { traceX.value; traceY.value; traceZ.value; stripBars.value; lastFrame.value; witnessHex.value; witnessVerified.value; lastB.value; bMag.value; this.requestUpdate(); }); } override willUpdate(changed: PropertyValues): void { // Apply parent-driven tab pin during willUpdate so the new tab value // participates in this same render pass — avoids the "update after // update completed" Lit warning that would fire if we did this in // updated(). if (changed.has('pinTab') && this.pinTab && this.tab !== this.pinTab) { this.tab = this.pinTab; } } private async verify(): Promise { const c = getClient(); if (!c) return; witnessVerified.value = 'pending'; pushLog('info', 'verifying witness over 256 frames…'); try { const exp = expectedWitness.value; const expBytes = new Uint8Array(32); for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16); const r = await c.verifyWitness(expBytes); if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`); } else { witnessVerified.value = 'fail'; const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join(''); witnessHex.value = actual; pushLog('err', `WITNESS MISMATCH actual=${actual.slice(0, 16)}…`); } } catch (e) { witnessVerified.value = 'fail'; pushLog('err', `verify failed: ${(e as Error).message}`); } } private renderHeader() { if (!this.expanded) return ''; const titles: Record = { signal: 'Signal inspector — live B-vector trace + frame stream', frame: 'Frame inspector — MagFrame v1 fields + raw bytes', witness: 'Witness panel — SHA-256 determinism gate', }; return html`

${titles[this.tab]}

${this.tab === 'signal' ? 'Real-time recovered field-vector and frame-stream sparkline. Both update at the running pipeline\'s frame rate. Use the Tunables panel in the sidebar to change f_s, f_mod, dt, and shot-noise behaviour.' : this.tab === 'frame' ? 'Decoded view of the most recent MagFrame: typed fields plus the raw 60-byte little-endian binary record (magic 0xC51A_6E70).' : 'Re-derive the SHA-256 witness for the canonical reference scene (seed=42, N=256) right now in your browser and compare against Proof::EXPECTED_WITNESS_HEX. Same inputs → same hash, byte-for-byte, across every machine and transport.'}

`; } private renderSignalTab() { const W = 320, H = 130, cy = 65, scale = 22; const cap = 200; const make = (arr: number[]) => { let p = ''; arr.forEach((v, i) => { const x = (i / Math.max(1, cap - 1)) * W; const y = cy - v * scale; p += (i === 0 ? 'M' : 'L') + ` ${x.toFixed(1)} ${y.toFixed(1)} `; }); return p; }; 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`
No frames yet. Press ▶ Run in the topbar (or hit Space) to start the live B-vector trace.
` : ''}
B-vector trace 3-axis · nT
${svg``} ${svg``} ${svg``} ${this.expanded ? html`
x: ${bnT[0].toFixed(3)} nT y: ${bnT[1].toFixed(3)} nT z: ${bnT[2].toFixed(3)} nT |B| ${(bMag.value * 1e9).toFixed(3)} nT
` : ''}
Frame stream live
${stripBars.value.map((v) => html`
`)}
${this.expanded ? html`
frames in window: ${stripBars.value.length} noise floor: ${lastFrame.value ? lastFrame.value.noiseFloorPtSqrtHz.toFixed(2) + ' pT/√Hz' : '—'}
` : ''}
`; } private renderFrameTab() { const f = lastFrame.value; const bytes = f?.raw; let hex = ''; if (bytes) { const arr = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')); hex = arr.slice(0, 60).join(' '); } return html` ${!f ? html`
No MagFrame to display yet. Start the pipeline (▶ Run) to populate.
` : ''}
MagFrame v1 fields 60 B
magic${f ? '0x' + f.magic.toString(16).toUpperCase() : '—'}
version${f?.version ?? '—'}
flags0x${(f?.flags ?? 0).toString(16).padStart(4, '0')}
sensor_id${f?.sensorId ?? '—'}
t_us${f ? f.tUs.toString() : '—'}
b_pT[0]${f ? f.bPt[0].toFixed(1) : '—'}
b_pT[1]${f ? f.bPt[1].toFixed(1) : '—'}
b_pT[2]${f ? f.bPt[2].toFixed(1) : '—'}
noise_floor${f ? f.noiseFloorPtSqrtHz.toFixed(2) : '—'}
temp_K${f ? f.temperatureK.toFixed(1) : '—'}
Hex dump LE
${hex || '—'}
${this.expanded ? html`
Layout (little-endian): magic(u32) version(u16) flags(u16) sensor_id(u16) _reserved(u16) t_us(u64) b_pt[3](f32) sigma_pt[3](f32) noise_floor(f32) temp_K(f32).
` : ''}
`; } private renderWitnessTab() { const status = witnessVerified.value; const cls = status === 'ok' ? 'ok' : status === 'fail' ? 'fail' : ''; const label = status === 'pending' ? 'Verifying…' : status === 'ok' ? '✓ Witness verified · determinism gate' : status === 'fail' ? '✗ Witness mismatch · audit required' : 'Verify witness'; const match = expectedWitness.value && witnessHex.value && expectedWitness.value === witnessHex.value; return html` ${this.expanded ? html`
Reference scene
Proof::REFERENCE
2 dipoles · 1 loop · 1 ferrous · 1 sensor
Seed
0x0000002A
canonical Proof::SEED
Sample count
256
Proof::N_SAMPLES
Status
${status === 'ok' ? '✓ matches' : status === 'fail' ? '✗ drift' : status === 'pending' ? '… running' : '— idle'}
${match ? 'byte-equivalent' : 'not yet verified'}
` : ''}
Expected (Proof::EXPECTED_WITNESS_HEX) SHA-256
${expectedWitness.value || '(loading…)'}
Actual (last verify) SHA-256
${witnessHex.value || '(not verified yet)'}
${this.expanded ? html`
What this verifies ADR-089 §5

Pressing Verify runs the canonical reference pipeline (Proof::generate) end-to-end inside this browser's WASM Worker: scene → Biot-Savart synthesis → material attenuation → NV ensemble → ADC + lock-in → concatenated MagFrame bytes → SHA-256.

If the resulting hash matches the constant pinned at build time (cc8de9b01b0ff5bd…), every constant — γ_e, D_GS, μ₀, T₂*, contrast, the PRNG stream, the frame layout, the pipeline ordering — is byte-identical to the published reference. If it doesn't match, something drifted; the dashboard names which.

This is the same regression test that runs in cargo test -p nvsim — running in your browser, against your own WASM build.

` : ''} `; } override render() { return html`
${this.renderHeader()} ${this.tab === 'signal' ? this.renderSignalTab() : this.tab === 'frame' ? this.renderFrameTab() : this.renderWitnessTab()}
`; } }