/* Command palette ⌘K. */ import { LitElement, html, css } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; import { toast } from './nv-toast'; import { openModal } from './nv-modal'; import { getClient, theme, expectedWitness, witnessHex, witnessVerified, pushLog, running, } from '../store/appStore'; interface Cmd { ico: string; label: string; kbd?: string; run: () => void; } @customElement('nv-palette') export class NvPalette extends LitElement { @state() private open = false; @state() private filter = ''; @state() private idx = 0; @query('#palette-input') private inputEl!: HTMLInputElement; static styles = css` :host { position: fixed; inset: 0; z-index: 220; background: rgba(0,0,0,0.5); opacity: 0; pointer-events: none; transition: opacity 0.15s; display: flex; justify-content: center; padding-top: 12vh; backdrop-filter: blur(4px); } :host([open]) { opacity: 1; pointer-events: auto; } .palette { width: min(560px, 92vw); 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); overflow: hidden; display: flex; flex-direction: column; max-height: 60vh; } .input { padding: 14px 16px; border-bottom: 1px solid var(--line); } input { width: 100%; background: transparent; border: none; outline: none; color: var(--ink); font-size: 14px; font-family: inherit; } .list { flex: 1; overflow-y: auto; padding: 4px; } .item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 12.5px; } .item.active { background: var(--bg-3); } .item .ico { width: 20px; text-align: center; color: var(--accent); } .item .lbl { flex: 1; } .item .kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); padding: 1px 5px; background: var(--bg-3); border-radius: 4px; } `; private cmds: Cmd[] = [ { ico: '▶', label: 'Run pipeline', kbd: 'Space', run: async () => { await getClient()?.run(); running.value = true; toast('Pipeline running', '▶'); } }, { ico: '❚', label: 'Pause pipeline', run: async () => { await getClient()?.pause(); running.value = false; toast('Paused', '❚❚'); } }, { ico: '+', label: 'New scene…', kbd: '⌘N', run: () => openModal({ title: 'New scene', body: `

Build a fresh magnetic scene. The dashboard generates the JSON and pushes it to the running pipeline (or you can copy the JSON for offline use).

`, buttons: [ { label: 'Cancel', variant: 'ghost' }, { label: 'Create', variant: 'primary', onClick: async () => { const root = document.querySelector('nv-app')?.shadowRoot?.querySelector('nv-modal')?.shadowRoot; if (!root) return; const name = (root.querySelector('#ns-name')?.value ?? 'custom').trim(); const m = parseFloat(root.querySelector('#ns-moment')?.value ?? '1e-6'); const d = parseFloat(root.querySelector('#ns-distance')?.value ?? '0.5'); const ferr = root.querySelector('#ns-ferrous')?.value === '1'; const mains = root.querySelector('#ns-mains')?.value === '1'; const scene = { dipoles: [{ position: [0, 0, d] as [number, number, number], moment: [0, 0, m] as [number, number, number] }], loops: mains ? [{ centre: [0, 1, 0] as [number, number, number], normal: [0, 1, 0] as [number, number, number], radius: 0.05, current: 2.0, n_segments: 64, }] : [], ferrous: ferr ? [{ position: [1, 0, 0] as [number, number, number], volume: 1e-4, susceptibility: 5000 }] : [], eddy: [], sensors: [[0, 0, 0] as [number, number, number]], ambient_field: [1e-6, 0, 0] as [number, number, number], }; await getClient()?.loadScene(scene); pushLog('ok', `scene ${name} loaded · 1 dipole · ${mains ? '1 loop · ' : ''}${ferr ? '1 ferrous · ' : ''}1 sensor`); toast(`Scene "${name}" loaded`, '+'); } }, ], }) }, { ico: '📦', label: 'Export proof bundle…', kbd: '⌘E', run: async () => { const c = getClient(); if (!c) return; pushLog('dbg', 'building proof bundle…'); try { const blob = await c.exportProofBundle(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `nvsim-proof-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); pushLog('ok', `proof bundle exported · ${blob.size} bytes`); toast(`Proof bundle saved (${blob.size} B)`, '📦'); } catch (e) { pushLog('err', `export failed: ${(e as Error).message}`); } } }, { ico: '⟳', label: 'Reset pipeline', kbd: '⌘R', run: () => openModal({ title: 'Reset pipeline?', body: '

Clears the frame stream and rewinds t to 0.

', buttons: [ { label: 'Cancel', variant: 'ghost' }, { label: 'Reset', variant: 'danger', onClick: async () => { await getClient()?.reset(); pushLog('warn', 'pipeline reset · t=0'); toast('Pipeline reset', '⟳'); } }, ], }) }, { ico: '✓', label: 'Verify witness', run: async () => { const c = getClient(); if (!c) return; witnessVerified.value = 'pending'; const exp = expectedWitness.value; const eb = new Uint8Array(32); for (let i = 0; i < 32; i++) eb[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16); const r = await c.verifyWitness(eb); if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; toast('Witness verified', '✓'); } else { witnessVerified.value = 'fail'; toast('Witness mismatch!', '✗'); } } }, { ico: '☼', label: 'Toggle theme', kbd: '⌘/', run: () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; } }, { ico: '⚙', label: 'Open settings', kbd: '⌘,', run: () => window.dispatchEvent(new CustomEvent('open-settings')) }, { ico: '?', label: 'Keyboard shortcuts…', run: () => openModal({ title: 'Keyboard shortcuts', body: `
⌘K / Ctrl K
Command palette
Space
Play / pause
⌘R
Reset
⌘,
Settings
⌘/
Toggle theme
\`
Debug HUD
1 · 2 · 3
Inspector tabs
Esc
Close modal/palette
/
Focus REPL
`, buttons: [{ label: 'Close', variant: 'primary' }], }) }, { ico: 'i', label: 'About nvsim…', run: () => openModal({ title: 'About nvsim', body: `

nvsim is a deterministic, byte-reproducible forward simulator for nitrogen-vacancy diamond magnetometry.

This dashboard runs nvsim as WASM in a Web Worker. Same (scene, config, seed) → byte-identical SHA-256 witness across runs and machines.

License: MIT OR Apache-2.0 · See ADR-089, ADR-092.

`, buttons: [{ label: 'Close', variant: 'primary' }], }) }, ]; override connectedCallback(): void { super.connectedCallback(); window.addEventListener('keydown', this.onKey); window.addEventListener('nv-palette', this.onOpen as EventListener); } override disconnectedCallback(): void { super.disconnectedCallback(); window.removeEventListener('keydown', this.onKey); window.removeEventListener('nv-palette', this.onOpen as EventListener); } private onKey = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); this.openPal(); } else if (e.key === 'Escape' && this.open) { this.closePal(); } else if (this.open) { if (e.key === 'ArrowDown') { this.idx = Math.min(this.cmds.length - 1, this.idx + 1); e.preventDefault(); } else if (e.key === 'ArrowUp') { this.idx = Math.max(0, this.idx - 1); e.preventDefault(); } else if (e.key === 'Enter') { this.runIdx(); e.preventDefault(); } } }; private onOpen = (): void => this.openPal(); private openPal(): void { this.open = true; this.setAttribute('open', ''); this.filter = ''; this.idx = 0; setTimeout(() => this.inputEl?.focus(), 0); } private closePal(): void { this.open = false; this.removeAttribute('open'); } private filtered(): Cmd[] { if (!this.filter.trim()) return this.cmds; const q = this.filter.toLowerCase(); return this.cmds.filter((c) => c.label.toLowerCase().includes(q)); } private runIdx(): void { const f = this.filtered(); const c = f[this.idx]; if (c) { c.run(); this.closePal(); } } override render() { const items = this.filtered(); return html`
{ this.filter = (e.target as HTMLInputElement).value; this.idx = 0; }} />
${items.map((c, i) => html`
{ this.idx = i; this.runIdx(); }}> ${c.ico} ${c.label} ${c.kbd ? html`${c.kbd}` : ''}
`)}
`; } }