From 2402c5a0043e070442a93e7fc831ea99360039d4 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 4 Mar 2026 12:17:57 -0300 Subject: [PATCH 1/6] operating room + minor ui improvements --- index.html | 5 +- js/actions.js | 258 +++++ js/audit.js | 126 ++- js/audit/chartRender.js | 142 ++- js/audit/findingsPrescriptions.js | 173 ++- js/dialog.js | 78 +- js/dom.js | 11 +- js/main.js | 14 +- js/operate/autostage.js | 256 +++++ js/operate/editor.js | 422 ++++++++ js/operate/guide.js | 380 +++++++ js/operate/health.js | 251 +++++ js/operate/nip11.js | 94 ++ js/operate/procedures.js | 267 +++++ js/operate/room.js | 592 +++++++++++ js/personalities.js | 263 ++++- style.css | 1615 +++++++++++++++++++++++++++-- 17 files changed, 4815 insertions(+), 132 deletions(-) create mode 100644 js/operate/autostage.js create mode 100644 js/operate/editor.js create mode 100644 js/operate/guide.js create mode 100644 js/operate/health.js create mode 100644 js/operate/nip11.js create mode 100644 js/operate/procedures.js create mode 100644 js/operate/room.js diff --git a/index.html b/index.html index 6a7c296..69bdece 100644 --- a/index.html +++ b/index.html @@ -53,12 +53,13 @@ maxlength="120"> + OR + +
-
diff --git a/js/actions.js b/js/actions.js index e527071..5ccb8ac 100644 --- a/js/actions.js +++ b/js/actions.js @@ -6,6 +6,7 @@ import { setSprite } from './sprite.js'; import { setRelayState, appendResultSection } from './relay-panel.js'; import { okBeep, errBeep } from './audio.js'; import { validateRelayUrl } from './relay-validation.js'; +import { RELAY_TIMEOUT_MS } from './config.js'; const PUBLISH_TIMEOUT_MS = 15_000; @@ -288,3 +289,260 @@ export async function unifyRelayLists() { const results = await publishUnifiedEvents(relaysToInvestigate, events); await reportUnifyResults(results, mergedRelays, relaysToInvestigate, fallbackMsg); } + +/** + * Delete orphaned KeyPackages β€” signs kind 5 deletion events for KPs found on relays + * not in the user's kind 10051 list, and publishes them to those relays. + */ +export async function deleteOrphanedKeyPackages() { + const state = getAuditState(); + if (!state) { await say('No audit data available.'); return; } + + const { orphanedKpRelays, relaysToInvestigate, pubkey } = state; + if (!orphanedKpRelays || orphanedKpRelays.length === 0) { + await say('No orphaned KeyPackages to delete.'); + return; + } + if (!window.nostr) { + await say(speak('noNip07ForAction') || 'This action requires a NIP-07 extension.'); + return; + } + + // Collect KP event IDs found on orphaned relays + // We need to query those relays for kind 443 events to get their IDs + setSprite('working', 'bounce'); + await say(speak('deleteKpStart', { count: orphanedKpRelays.length }) + || `Deleting orphaned KeyPackages from ${orphanedKpRelays.length} relay(s)...`); + + // Find KP IDs that exist on orphaned relays β€” check kpEventsCollected + const orphanedKpIds = []; + const pool = new SimplePool(); + + // Query orphaned relays for kind 443 events + for (const relay of orphanedKpRelays) { + try { + const events = await new Promise((resolve) => { + const collected = []; + let done = false; + const sub = pool.subscribeMany([relay], { authors: [pubkey], kinds: [443] }, { + onevent(ev) { collected.push(ev); }, + oneose() { + if (done) return; + done = true; + try { sub.close(); } catch { /* ignore */ } + resolve(collected); + }, + }); + setTimeout(() => { + if (done) return; + done = true; + try { sub.close(); } catch { /* ignore */ } + resolve(collected); + }, RELAY_TIMEOUT_MS); + }); + for (const ev of events) { + if (ev.id && !orphanedKpIds.includes(ev.id)) orphanedKpIds.push(ev.id); + } + } catch (e) { + console.error('Failed to query orphaned relay for KPs', relay, e); + } + } + + if (orphanedKpIds.length === 0) { + pool.close(orphanedKpRelays); + setSprite('idle'); + await say('No KeyPackage events found on orphaned relays β€” they may have already been cleaned up.'); + return; + } + + await say(speak('deleteKpSign')); + + // Sign deletion events + const deleteEvents = []; + try { + for (const kpId of orphanedKpIds) { + const unsigned = { + kind: 5, + created_at: Math.floor(Date.now() / 1000), + tags: [['e', kpId]], + content: '', + pubkey, + }; + const signed = await window.nostr.signEvent(unsigned); + deleteEvents.push(signed); + } + } catch (e) { + pool.close(orphanedKpRelays); + setSprite('error', 'error'); + errBeep(); + await say(speak('deleteKpFail') || `Signing failed: ${e?.message || 'extension declined'}`); + return; + } + + // Publish to orphaned relays + investigation relays + const publishTargets = [...new Set([...orphanedKpRelays, ...relaysToInvestigate])]; + const results = []; + for (const relay of publishTargets) { + setRelayState(relay, 'connecting', 'DELETING'); + let relayOk = true; + for (const ev of deleteEvents) { + try { + await Promise.race([ + pool.publish([relay], ev), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), PUBLISH_TIMEOUT_MS), + ), + ]); + } catch { + relayOk = false; + } + } + setRelayState(relay, relayOk ? 'ok' : 'error', relayOk ? 'DELETED' : 'FAIL'); + results.push({ relay, ok: relayOk }); + } + + pool.close(publishTargets); + + const succeeded = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + const items = results.map(r => ({ + type: r.ok ? 'ok' : 'err', + text: `${r.relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')} β€” ${r.ok ? 'delete sent' : 'failed'}`, + })); + appendResultSection('ORPHANED KP DELETION RESULTS', items); + + if (failed === 0) { + setSprite('success', 'success'); + okBeep(); + await say(speak('deleteKpDone', { count: deleteEvents.length })); + } else { + setSprite('error', 'error'); + errBeep(); + await say(`${succeeded} relay(s) accepted deletions, ${failed} failed.`); + } +} + +/** + * Delete all kind 4 (NIP-04 DM) events by signing kind 5 deletion events. + * Queries all investigation relays for kind 4 events, then publishes deletions. + */ +export async function deleteDeprecatedKind4() { + const state = getAuditState(); + if (!state) { await say('No audit data available.'); return; } + + const { relaysToInvestigate, pubkey } = state; + if (!window.nostr) { + await say(speak('noNip07ForAction') || 'This action requires a NIP-07 extension.'); + return; + } + + setSprite('working', 'bounce'); + await say('Scanning for NIP-04 (kind 4) events to delete...'); + + // Query all relays for kind 4 events + const pool = new SimplePool(); + const kind4Ids = new Set(); + + for (const relay of relaysToInvestigate) { + setRelayState(relay, 'connecting', 'SCANNING'); + try { + const events = await new Promise((resolve) => { + const collected = []; + let done = false; + const sub = pool.subscribeMany([relay], { authors: [pubkey], kinds: [4], limit: 500 }, { + onevent(ev) { collected.push(ev); }, + oneose() { + if (done) return; + done = true; + try { sub.close(); } catch { /* ignore */ } + resolve(collected); + }, + }); + setTimeout(() => { + if (done) return; + done = true; + try { sub.close(); } catch { /* ignore */ } + resolve(collected); + }, RELAY_TIMEOUT_MS); + }); + for (const ev of events) { + if (ev.id) kind4Ids.add(ev.id); + } + setRelayState(relay, 'ok', `${events.length} k4`); + } catch (e) { + console.error('Failed to query relay for kind 4', relay, e); + setRelayState(relay, 'error', 'FAIL'); + } + } + + if (kind4Ids.size === 0) { + pool.close(relaysToInvestigate); + setSprite('idle'); + await say('No kind 4 (NIP-04 DM) events found β€” nothing to delete.'); + return; + } + + await say(`Found ${kind4Ids.size} kind 4 event(s). Requesting NIP-07 signatures for deletion...`); + + // Sign deletion events + const deleteEvents = []; + try { + // Batch into a single kind 5 with multiple e tags for efficiency + const unsigned = { + kind: 5, + created_at: Math.floor(Date.now() / 1000), + tags: [...kind4Ids].map(id => ['e', id]), + content: 'NIP-04 DM cleanup β€” migrating to NIP-17/Marmot', + pubkey, + }; + const signed = await window.nostr.signEvent(unsigned); + deleteEvents.push(signed); + } catch (e) { + pool.close(relaysToInvestigate); + setSprite('error', 'error'); + errBeep(); + await say(`Signing failed β€” ${e?.message || 'extension declined'}.`); + return; + } + + // Publish to all relays + const results = []; + for (const relay of relaysToInvestigate) { + setRelayState(relay, 'connecting', 'DELETING'); + let relayOk = true; + for (const ev of deleteEvents) { + try { + await Promise.race([ + pool.publish([relay], ev), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), PUBLISH_TIMEOUT_MS), + ), + ]); + } catch { + relayOk = false; + } + } + setRelayState(relay, relayOk ? 'ok' : 'error', relayOk ? 'DELETED' : 'FAIL'); + results.push({ relay, ok: relayOk }); + } + + pool.close(relaysToInvestigate); + + const succeeded = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + const items = results.map(r => ({ + type: r.ok ? 'ok' : 'err', + text: `${r.relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')} β€” ${r.ok ? 'delete sent' : 'failed'}`, + })); + appendResultSection('NIP-04 DELETION RESULTS', items); + + if (failed === 0) { + setSprite('success', 'success'); + okBeep(); + await say(`Done. ${kind4Ids.size} kind 4 event(s) deleted across ${succeeded} relay(s). Welcome to the future of encrypted messaging.`); + } else { + setSprite('error', 'error'); + errBeep(); + await say(`${succeeded} relay(s) accepted deletions, ${failed} failed.`); + } +} diff --git a/js/audit.js b/js/audit.js index e3bba31..2c342b0 100644 --- a/js/audit.js +++ b/js/audit.js @@ -1,6 +1,6 @@ import { nip19, SimplePool } from 'https://esm.sh/nostr-tools'; import { DEFAULT_RELAYS } from './config.js'; -import { npubInput, nip07Btn, nextBtn, auditBtn, relayList, getChartActionButtons } from './dom.js'; +import { npubInput, nip07Btn, nextBtn, auditBtn, cancelBtn, relayList, getChartActionButtons } from './dom.js'; import { errBeep, okBeep } from './audio.js'; import { say, clearQueue, setOnAllDone } from './dialog.js'; import { setSprite, startInvestigating, stopInvestigating } from './sprite.js'; @@ -349,8 +349,26 @@ async function runCompileAndRenderPhase(rawNpub, compileParams, kpEventsCollecte const compiled = compileFindingsAndPrescriptions(compileParams); lastAuditState = compiled.lastAuditState; + // Attach compiled result to audit state for OR access + lastAuditState.compiledResult = { + findings: compiled.findings, + findingsWithHints: compiled.findingsWithHints, + prescriptions: compiled.prescriptions, + canRebroadcast: compiled.canRebroadcast, + canDeleteKps: compiled.canDeleteKps, + canUnifyRelays, + allOk: compiled.allOk, + hasFailures: compiled.hasFailures, + hasWarnings: compiled.hasWarnings, + doctorNotes: compiled.doctorNotes, + categories: compiled.categories, + }; + + const canOperate = Boolean(window.nostr) && Boolean(compiled.lastAuditState?.fromNip07); + const { cardHTML } = renderChart({ findings: compiled.findings, + findingsWithHints: compiled.findingsWithHints, prescriptions: compiled.prescriptions, verdictClass: compiled.verdictClass, verdictIcon: compiled.verdictIcon, @@ -360,16 +378,57 @@ async function runCompileAndRenderPhase(rawNpub, compileParams, kpEventsCollecte totalRelays: compiled.totalRelays, canRebroadcast: compiled.canRebroadcast, canDeleteKps: compiled.canDeleteKps, + canDeleteOrphanedKps: compiled.canDeleteOrphanedKps, + canDeleteKind4: compiled.canDeleteKind4, canUnifyRelays, + canOperate, rawNpub, - }, getDisplayName); + doctorNotes: compiled.doctorNotes, + }, getDisplayName, speak); + + // Collapse relay status rows and result sections behind an expandable summary + const existingRows = relayList.querySelectorAll('.relay-row, .result-section'); + if (existingRows.length > 0) { + const collapser = document.createElement('div'); + collapser.className = 'relay-rows-collapsed'; + collapser.dataset.count = existingRows.length; + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'relay-collapse-toggle'; + toggleBtn.textContent = `β–Ά VIEW RELAY DETAILS (${existingRows.length} sections)`; + toggleBtn.addEventListener('click', () => { + collapser.classList.toggle('relay-rows-expanded'); + toggleBtn.textContent = collapser.classList.contains('relay-rows-expanded') + ? `β–Ό HIDE RELAY DETAILS` + : `β–Ά VIEW RELAY DETAILS (${existingRows.length} sections)`; + }); + collapser.appendChild(toggleBtn); + for (const row of existingRows) { + collapser.appendChild(row); + } + relayList.appendChild(collapser); + } const cardContainer = document.createElement('div'); cardContainer.innerHTML = cardHTML; const chartEl = cardContainer.firstElementChild; relayList.appendChild(chartEl); - const { rebroadcast: rebroadcastBtn, deleteKps: deleteKpBtn, unifyRelays: unifyRelaysBtn } = getChartActionButtons(chartEl); + // Wire up collapsible pass-group toggle in STATUS EFFECTS + const passGroupToggle = chartEl.querySelector('[data-action="toggle-dx-group"]'); + if (passGroupToggle) { + passGroupToggle.addEventListener('click', () => { + const group = passGroupToggle.closest('.dx-group'); + if (group) group.classList.toggle('dx-group-collapsed'); + }); + } + + const { + rebroadcast: rebroadcastBtn, + deleteKps: deleteKpBtn, + unifyRelays: unifyRelaysBtn, + deleteOrphanedKps: deleteOrphanedKpsBtn, + deleteKind4: deleteKind4Btn, + } = getChartActionButtons(chartEl); if (rebroadcastBtn) { rebroadcastBtn.addEventListener('click', async () => { const { rebroadcastProfileAndContacts } = await import('./actions.js'); @@ -428,6 +487,63 @@ async function runCompileAndRenderPhase(rawNpub, compileParams, kpEventsCollecte }); } + if (deleteOrphanedKpsBtn) { + deleteOrphanedKpsBtn.addEventListener('click', async () => { + const { deleteOrphanedKeyPackages } = await import('./actions.js'); + deleteOrphanedKpsBtn.disabled = true; + deleteOrphanedKpsBtn.classList.add('working'); + deleteOrphanedKpsBtn.textContent = 'WORKING…'; + let succeeded = false; + try { + await deleteOrphanedKeyPackages(); + succeeded = true; + } catch (err) { + console.error('Delete orphaned KPs action failed', err); + } finally { + deleteOrphanedKpsBtn.disabled = false; + deleteOrphanedKpsBtn.classList.remove('working'); + deleteOrphanedKpsBtn.textContent = succeeded ? 'DONE' : 'ERROR'; + } + }); + } + if (deleteKind4Btn) { + deleteKind4Btn.addEventListener('click', async () => { + const { deleteDeprecatedKind4 } = await import('./actions.js'); + deleteKind4Btn.disabled = true; + deleteKind4Btn.classList.add('working'); + deleteKind4Btn.textContent = 'WORKING…'; + let succeeded = false; + try { + await deleteDeprecatedKind4(); + succeeded = true; + } catch (err) { + console.error('Delete kind 4 action failed', err); + } finally { + deleteKind4Btn.disabled = false; + deleteKind4Btn.classList.remove('working'); + deleteKind4Btn.textContent = succeeded ? 'DONE' : 'ERROR'; + } + }); + } + + const { operatingRoom: orBtn } = getChartActionButtons(chartEl); + if (orBtn) { + orBtn.addEventListener('click', async () => { + const { enterOperatingRoom } = await import('./operate/room.js'); + orBtn.disabled = true; + orBtn.classList.add('working'); + orBtn.textContent = 'PREPPING…'; + try { + await enterOperatingRoom(); + } catch (err) { + console.error('Operating Room entry failed', err); + orBtn.disabled = false; + orBtn.classList.remove('working'); + orBtn.textContent = 'βš• OPERATING ROOM'; + } + }); + } + relayList.scrollTop = relayList.scrollHeight; if (compiled.allOk) { setSprite('success', 'success'); @@ -483,6 +599,7 @@ export async function startAudit(opts = {}) { auditBtn.disabled = true; nip07Btn.disabled = true; nextBtn.classList.add('hidden'); + cancelBtn.classList.remove('hidden'); clearRelayPanel(); addScanBar(); setScanProgress(0); @@ -670,9 +787,10 @@ export async function startAudit(opts = {}) { try { pool.close([...new Set([...DEFAULT_RELAYS, ...relaysToInvestigateForCleanup, ...marmotRelaysForCleanup])]); } catch { /* ignore */ } } finally { isAuditing = false; + cancelBtn.classList.add('hidden'); setOnAllDone(() => { auditBtn.disabled = false; - nip07Btn.disabled = false; + nip07Btn.disabled = !window.nostr; nextBtn.classList.remove('hidden'); }); } diff --git a/js/audit/chartRender.js b/js/audit/chartRender.js index 648a6f8..8420907 100644 --- a/js/audit/chartRender.js +++ b/js/audit/chartRender.js @@ -2,7 +2,7 @@ * Patient chart HTML generation. */ -import { html } from '../html.js'; +import { html, escapeHtml } from '../html.js'; const NPUB_SHORT_LEN = 20; @@ -53,23 +53,65 @@ function buildConditionSection( `; } -function buildEffectsSection(allEffects) { - if (allEffects.length === 0) return ''; +function buildDoctorNotesSection(doctorNote) { + if (!doctorNote) return ''; + + return html` +
+ +
+
${doctorNote}
+
+
+ `; +} - const effectRows = allEffects.map((effect) => { - const icon = effect.type === 'pass' ? 'βœ”' : effect.type === 'warn' ? '!' : 'βœ–'; +function buildEffectGroup(groupLabel, groupClass, items) { + if (items.length === 0) return ''; + const icon = groupClass === 'fail' ? 'βœ–' : groupClass === 'warn' ? '!' : 'βœ”'; + const isPassGroup = groupClass === 'pass'; + + const rows = items.map((effect) => { + const hintRow = effect.hint + ? html`
${escapeHtml(effect.hint)}
` + : ''; return html` -
+
${icon} ${effect.text} + ${hintRow}
`; }); + return html` +
+
+ ${groupLabel} + ${items.length} + ${isPassGroup ? html`β–Έ` : ''} +
+
${rows}
+
+ `; +} + +function buildEffectsSection(allEffects) { + if (allEffects.length === 0) return ''; + + const fails = allEffects.filter(e => e.type === 'fail'); + const warns = allEffects.filter(e => e.type === 'warn'); + const passes = allEffects.filter(e => e.type === 'pass'); + return html`
-
${effectRows}
+
+ ${buildEffectGroup('⚠ AILMENTS', 'fail', fails)} + ${buildEffectGroup('β—ˆ CAUTIONS', 'warn', warns)} + ${buildEffectGroup('✦ VITALS OK', 'pass', passes)} +
`; } @@ -79,12 +121,20 @@ function resolvePrescriptionAction(rx) { const text = String(rx?.text ?? rx).toLowerCase(); if (text.includes('rebroadcast') && text.includes('profile')) return 'rebroadcast'; if (text.includes('delete events') && text.includes('keypackage')) return 'delete-kps'; + if (text.includes('delete orphaned keypackages')) return 'delete-orphaned-kps'; if (text.includes('unify your k3 and k10002')) return 'unify-relays'; + if (text.includes('reduce kind 10002')) return 'reduce-relays'; + if (text.includes('migrate from nip-04') || (text.includes('nip-04') && text.includes('kind 4'))) return 'delete-kind4'; return null; } -function buildTreatmentSection(prescriptions, canRebroadcast, canDeleteKps, canUnifyRelays) { - if (prescriptions.length === 0) return ''; +function buildTreatmentSection(prescriptions, actionFlags, canOperate) { + if (prescriptions.length === 0 && !canOperate) return ''; + + const { + canRebroadcast, canDeleteKps, canUnifyRelays, + canDeleteOrphanedKps, canDeleteKind4, + } = actionFlags; const requiresNip07Attrs = !window.nostr ? ' disabled title="Requires NIP-07 extension"' : ''; const treatmentRows = prescriptions.map((rx) => { @@ -93,18 +143,26 @@ function buildTreatmentSection(prescriptions, canRebroadcast, canDeleteKps, canU let actionButton = ''; if (action === 'rebroadcast' && canRebroadcast) { - actionButton = html``; + actionButton = html``; } else if (action === 'delete-kps' && canDeleteKps) { actionButton = html` - + + `; + } else if (action === 'delete-orphaned-kps' && canDeleteOrphanedKps) { + actionButton = html` + `; } else if (action === 'unify-relays' && canUnifyRelays) { actionButton = html` - + + `; + } else if (action === 'reduce-relays') { + actionButton = html` + + `; + } else if (action === 'delete-kind4' && canDeleteKind4) { + actionButton = html` + `; } @@ -116,10 +174,22 @@ function buildTreatmentSection(prescriptions, canRebroadcast, canDeleteKps, canU `; }); + const operateBtn = canOperate ? html` +
+ + Manage all relay lists in one place. Add, remove, and edit relays across NIP-65, Inbox, KeyPackage, and Contacts. +
+ ` : ''; + return html`
-
${treatmentRows}
+
+ ${treatmentRows} + ${operateBtn} +
`; } @@ -131,11 +201,13 @@ function buildChartSignature(doctorName) { /** * @param {Object} params * @param {Function} getDisplayName + * @param {Function} [speakFn] - speak() from personalities.js for doctor notes * @returns {{ cardHTML: string }} */ -export function renderChart(params, getDisplayName) { +export function renderChart(params, getDisplayName, speakFn) { const { findings, + findingsWithHints, prescriptions, verdictClass, verdictIcon, @@ -145,8 +217,12 @@ export function renderChart(params, getDisplayName) { totalRelays, canRebroadcast, canDeleteKps, + canDeleteOrphanedKps, + canDeleteKind4, canUnifyRelays, + canOperate, rawNpub, + doctorNotes, } = params; const hpTotal = findings.pass.length + findings.warn.length + findings.fail.length; @@ -156,11 +232,25 @@ export function renderChart(params, getDisplayName) { const chartDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); const shortNpub = rawNpub.slice(0, NPUB_SHORT_LEN) + '…'; - const allEffects = [ - ...findings.fail.map(t => ({ type: 'fail', text: t })), - ...findings.warn.map(t => ({ type: 'warn', text: t })), - ...findings.pass.map(t => ({ type: 'pass', text: t })), - ]; + // Use findingsWithHints if available, otherwise fallback to raw findings + const hinted = findingsWithHints || null; + const allEffects = hinted + ? [ + ...hinted.fail.map(f => ({ type: 'fail', text: f.text, hint: f.hint })), + ...hinted.warn.map(f => ({ type: 'warn', text: f.text, hint: f.hint })), + ...hinted.pass.map(f => ({ type: 'pass', text: f.text, hint: f.hint })), + ] + : [ + ...findings.fail.map(t => ({ type: 'fail', text: t })), + ...findings.warn.map(t => ({ type: 'warn', text: t })), + ...findings.pass.map(t => ({ type: 'pass', text: t })), + ]; + + // Generate doctor's note text via personality system + let doctorNoteText = null; + if (doctorNotes && speakFn) { + doctorNoteText = speakFn(doctorNotes.noteKey, doctorNotes.noteVars); + } const cardHTML = html`
@@ -175,12 +265,12 @@ export function renderChart(params, getDisplayName) { hpClass, totalRelays, )} + ${buildDoctorNotesSection(doctorNoteText)} ${buildEffectsSection(allEffects)} ${buildTreatmentSection( prescriptions, - canRebroadcast, - canDeleteKps, - canUnifyRelays, + { canRebroadcast, canDeleteKps, canUnifyRelays, canDeleteOrphanedKps, canDeleteKind4 }, + canOperate, )} ${buildChartSignature(doctorName)}
diff --git a/js/audit/findingsPrescriptions.js b/js/audit/findingsPrescriptions.js index 66d53aa..5a5e8ad 100644 --- a/js/audit/findingsPrescriptions.js +++ b/js/audit/findingsPrescriptions.js @@ -1,7 +1,140 @@ /** - * Findings compilation, prescriptions, verdict, and closing-message helpers. + * Findings compilation, prescriptions, verdict, closing-message helpers, + * and doctor recommendation generation. */ +/** + * Per-finding recommendation hints. + * Maps a finding text pattern (lowercase) to a one-liner explaining + * why it matters and what to do. + * @type {Array<{pattern: string, hint: string}>} + */ +const FINDING_HINTS = [ + { pattern: 'invalid relay url', hint: 'Broken URLs prevent relay discovery β€” remove them and use valid wss:// addresses.' }, + { pattern: 'invalid keypackage relay', hint: 'KeyPackage relay URLs must be valid wss:// β€” fix or remove them from k10051.' }, + { pattern: 'profile (kind 0) not found', hint: 'No profile means clients can\'t display your name, picture, or NIP-05. Publish a kind 0.' }, + { pattern: 'profile (kind 0) in sync', hint: 'All relays have your latest profile β€” no action needed.' }, + { pattern: 'profile (kind 0) outdated', hint: 'Some relays have an old version β€” rebroadcast to push the latest.' }, + { pattern: 'profile (kind 0) missing', hint: 'Some relays don\'t have your profile at all β€” rebroadcast to fix.' }, + { pattern: 'contacts list (kind 3) not found', hint: 'No contacts means clients can\'t build your social graph. Publish a kind 3.' }, + { pattern: 'contacts (kind 3) in sync', hint: 'All relays have your latest contacts β€” consistent.' }, + { pattern: 'contacts (kind 3) outdated', hint: 'Some relays have stale contacts β€” rebroadcast to sync.' }, + { pattern: 'contacts (kind 3) missing', hint: 'Some relays are missing your contacts β€” rebroadcast.' }, + { pattern: 'no keypackage relay list (kind 10051)', hint: 'Without k10051, nobody can find your KeyPackages. Marmot messaging requires this.' }, + { pattern: 'keypackage relay list (kind 10051) published', hint: 'KeyPackage advertisement is live β€” clients can discover your KPs.' }, + { pattern: 'keypackage relay(s) advertised', hint: 'Relay addresses are present in your k10051. Others can find your KPs there.' }, + { pattern: 'kind 10051 contains no relay tags', hint: 'Your k10051 exists but has no relays. Add at least one relay URL.' }, + { pattern: 'no inbox relay list (kind 10050)', hint: 'Without k10050, giftwrapped messages and Marmot invites can\'t reach you.' }, + { pattern: 'inbox relay list (kind 10050) published', hint: 'Inbox relay advertisement is live β€” giftwraps can find you.' }, + { pattern: 'inbox relay(s) advertised', hint: 'Relay addresses present in k10050 for giftwrap delivery.' }, + { pattern: 'kind 10050 contains no valid relay tags', hint: 'Your k10050 exists but has no valid relays. Add at least one.' }, + { pattern: 'invalid inbox relay', hint: 'Inbox relay URL is malformed β€” fix the URL in your k10050.' }, + { pattern: 'keypackage(s) found on', hint: 'Orphaned KPs are invisible to other users. Add those relays to k10051 or delete the KPs.' }, + { pattern: 'whitenoise login gate', hint: 'WhiteNoise needs k10002, k10050, and k10051 to log in. Missing any blocks access.' }, + { pattern: 'nip-65: no read relays', hint: 'Clients need at least one read relay to deliver replies to you.' }, + { pattern: 'nip-65: no write relays', hint: 'Clients need at least one write relay to know where you publish.' }, + { pattern: 'nip-65: excessive relay count', hint: 'Too many relays slow down clients. Trim to 5-8 reliable ones.' }, + { pattern: 'kind 10002 (nip-65) has malformed', hint: 'Tags array is broken. Republish k10002 with proper r-tagged relay URLs.' }, + { pattern: 'relay lists diverge', hint: 'k3 and k10002 show different relays. Unify them so all clients see the same list.' }, + { pattern: 'no nip-17 dm inbox', hint: 'Without k10050, NIP-17 encrypted DMs cannot be delivered to you.' }, + { pattern: 'no blossom media server', hint: 'Clients won\'t know where to upload media for you. Publish a k10063.' }, + { pattern: 'deprecated nip-04', hint: 'NIP-04 DMs leak metadata. Migrate to NIP-17 or Marmot for privacy.' }, + { pattern: 'deprecated kind 2', hint: 'Kind 2 is obsolete. Use NIP-65 (k10002) for relay recommendations.' }, + { pattern: 'all keypackages pass', hint: 'MIP-00/01 fully compliant β€” your Marmot setup is solid.' }, + { pattern: 'all tags valid', hint: 'KeyPackage tags pass validation β€” encoding, ciphersuite, and extensions are correct.' }, +]; + +/** + * Get the recommendation hint for a finding text. + * @param {string} findingText + * @returns {string|null} + */ +export function getHintForFinding(findingText) { + const lower = (findingText || '').toLowerCase(); + for (const { pattern, hint } of FINDING_HINTS) { + if (lower.includes(pattern)) return hint; + } + return null; +} + +/** + * Generate a structured Doctor's Notes summary based on findings and verdict. + * Returns a key for speak() that the personality system uses to render the note. + * + * @param {Object} params + * @param {Object} params.findings - { pass: [], warn: [], fail: [] } + * @param {boolean} params.allOk + * @param {boolean} params.hasFailures + * @param {boolean} params.hasWarnings + * @param {string[]} params.categories - from categorizeFailures() + * @param {Object} params.auditCtx + * @returns {{ noteKey: string, noteVars: Object }} + */ +export function generateDoctorNotes(params) { + const { findings, allOk, hasFailures, hasWarnings, categories, auditCtx } = params; + + if (allOk) { + return { + noteKey: 'doctorNoteClean', + noteVars: { + passCount: findings.pass.length, + relayCount: auditCtx.totalRelays || 0, + }, + }; + } + + if (!hasFailures && hasWarnings) { + const warnCount = findings.warn.length; + const isRelayDivergence = categories.includes('sync') || auditCtx.relayDiverges; + const isBloated = auditCtx.nip65Bloated; + return { + noteKey: 'doctorNoteMinor', + noteVars: { + warnCount, + isRelayDivergence, + isBloated, + passCount: findings.pass.length, + }, + }; + } + + // Has failures β€” determine the main category + const failCount = findings.fail.length; + const warnCount = findings.warn.length; + + const hasRelayConfig = categories.includes('relay-config'); + const hasSync = categories.includes('sync'); + const hasMarmot = categories.includes('marmot-foundation'); + const hasKp = categories.includes('keypackage'); + + let noteKey = 'doctorNoteCritical'; + if (hasRelayConfig && !hasSync && !hasMarmot && !hasKp) { + noteKey = 'doctorNoteRelayConfig'; + } else if (hasSync && !hasRelayConfig && !hasMarmot && !hasKp) { + noteKey = 'doctorNoteSync'; + } else if (hasMarmot && !hasRelayConfig && !hasSync) { + noteKey = 'doctorNoteMarmot'; + } else if (hasKp && !hasRelayConfig && !hasSync && !hasMarmot) { + noteKey = 'doctorNoteKeyPackage'; + } else if (categories.length >= 2) { + noteKey = 'doctorNoteMultiple'; + } + + return { + noteKey, + noteVars: { + failCount, + warnCount, + passCount: findings.pass.length, + hasRelayConfig, + hasSync, + hasMarmot, + hasKp, + categoryCount: categories.length, + }, + }; +} + const FAILURE_CATEGORIES = { 'relay-config': ['Invalid relay', 'invalid relay', 'kind 10051 relay', 'relay URLs'], 'sync': ['sync', 'outdated', 'missing', 'stale', 'Profile (kind 0)', 'Contacts (kind 3)', 'not found'], @@ -478,7 +611,9 @@ export function generatePrescriptions( ); const canRebroadcast = (staleRelays > 0 || missingRelays > 0); const canDeleteKps = missingITagIds.length > 0; - return { prescriptions: uniqueRx, canRebroadcast, canDeleteKps }; + const canDeleteOrphanedKps = orphanedKpRelays && orphanedKpRelays.length > 0; + const canDeleteKind4 = auditCtx.hasDeprecatedK4; + return { prescriptions: uniqueRx, canRebroadcast, canDeleteKps, canDeleteOrphanedKps, canDeleteKind4 }; } /** @@ -566,9 +701,36 @@ export function compileFindingsAndPrescriptions(params) { orphanedKpRelays, best10050, ); - const { prescriptions: uniqueRx, canDeleteKps } = rxResult; + const { prescriptions: uniqueRx, canDeleteKps, canDeleteOrphanedKps, canDeleteKind4 } = rxResult; const canRebroadcast = Boolean(rxResult.canRebroadcast) && !!(bestK0 || bestK3); + // Generate doctor's notes and per-finding hints + const categories = categorizeFailures(findingsResult.findings.fail.map(f => f)); + const doctorNotes = generateDoctorNotes({ + findings: findingsResult.findings, + allOk: findingsResult.allOk, + hasFailures: findingsResult.hasFailures, + hasWarnings: findingsResult.hasWarnings, + categories, + auditCtx, + }); + + // Attach hints to findings for per-finding recommendations + const findingsWithHints = { + pass: findingsResult.findings.pass.map(text => ({ + text, + hint: getHintForFinding(text), + })), + warn: findingsResult.findings.warn.map(text => ({ + text, + hint: getHintForFinding(text), + })), + fail: findingsResult.findings.fail.map(text => ({ + text, + hint: getHintForFinding(text), + })), + }; + const lastAuditState = { bestK0, bestK3, @@ -591,6 +753,7 @@ export function compileFindingsAndPrescriptions(params) { return { findings, + findingsWithHints, prescriptions: uniqueRx, verdictClass, verdictIcon, @@ -601,10 +764,14 @@ export function compileFindingsAndPrescriptions(params) { lastAuditState, canRebroadcast, canDeleteKps, + canDeleteOrphanedKps, + canDeleteKind4, missingITagIds, syncedRelays, staleRelays, missingRelays, totalRelays, + doctorNotes, + categories, }; } diff --git a/js/dialog.js b/js/dialog.js index de7f683..3d9e9c0 100644 --- a/js/dialog.js +++ b/js/dialog.js @@ -1,4 +1,4 @@ -import { dialogText, dialogBox } from './dom.js'; +import { dialogText, dialogBox, dialogArrow, dialogHint } from './dom.js'; import { typeBeep } from './audio.js'; import { isJeff } from './jeff.js'; @@ -8,13 +8,26 @@ let typeTimer = null; let autoTimer = null; let onAllDone = null; -const AUTO_ADVANCE_MS = 400; +/** + * Delay before auto-advancing to next message (ms). + * Set to 0 to disable auto-advance (click-to-advance only). + */ +let autoAdvanceMs = 0; + +/** + * Enable or disable auto-advance mode. + * @param {boolean} enabled + */ +export function setAutoAdvance(enabled) { + autoAdvanceMs = enabled ? 400 : 0; +} export function clearQueue() { msgQueue = []; clearInterval(typeTimer); clearTimeout(autoTimer); isTyping = false; + _hideAdvanceIndicators(); } export function setOnAllDone(fn) { @@ -32,8 +45,17 @@ export function say(text, onDone = null) { }); } +function _appendToLog(content) { + const entry = document.createElement('div'); + entry.className = 'dialog-msg'; + entry.innerHTML = content; + dialogText.appendChild(entry); + dialogText.scrollTop = dialogText.scrollHeight; +} + function _nextMsg() { clearTimeout(autoTimer); + _hideAdvanceIndicators(); if (msgQueue.length === 0) { dialogText.classList.add('done'); @@ -47,24 +69,29 @@ function _nextMsg() { const { text, onDone } = msgQueue[0]; if (text.includes('<')) { - dialogText.innerHTML = text; + _appendToLog(text); _finishMsg(onDone); return; } if (isJeff) { - dialogText.textContent = text; + _appendToLog(text); _finishMsg(onDone); return; } - dialogText.textContent = ''; + // Typewriter effect: create a new log entry and type into it + const entry = document.createElement('div'); + entry.className = 'dialog-msg'; + dialogText.appendChild(entry); + let i = 0; clearInterval(typeTimer); typeTimer = setInterval(() => { if (i % 3 === 0) typeBeep(); - dialogText.textContent += text.charAt(i); + entry.textContent += text.charAt(i); i++; + dialogText.scrollTop = dialogText.scrollHeight; if (i >= text.length) { clearInterval(typeTimer); _finishMsg(onDone); @@ -77,24 +104,49 @@ function _finishMsg(onDone) { if (onDone) onDone(); if (msgQueue.length > 1) { - autoTimer = setTimeout(() => { - msgQueue.shift(); - _nextMsg(); - }, AUTO_ADVANCE_MS); + if (autoAdvanceMs > 0) { + autoTimer = setTimeout(() => { + msgQueue.shift(); + _nextMsg(); + }, autoAdvanceMs); + } else { + // Click-to-advance: show indicators and wait + _showAdvanceIndicators(); + } } else { msgQueue = []; dialogText.classList.add('done'); + _hideAdvanceIndicators(); if (onAllDone) { const f = onAllDone; onAllDone = null; f(); } } } +function _showAdvanceIndicators() { + if (dialogArrow) dialogArrow.classList.remove('hidden'); + if (dialogHint) dialogHint.classList.remove('hidden'); +} + +function _hideAdvanceIndicators() { + if (dialogArrow) dialogArrow.classList.add('hidden'); + if (dialogHint) dialogHint.classList.add('hidden'); +} + function _advance() { clearTimeout(autoTimer); + _hideAdvanceIndicators(); if (isTyping) { clearInterval(typeTimer); - const text = msgQueue[0]?.text ?? ''; - if (!text.includes('<')) dialogText.textContent = text; - _finishMsg(msgQueue[0]?.onDone); + const msg = msgQueue[0]; + const text = msg?.text ?? ''; + // Complete the typewriter instantly + const lastEntry = dialogText.querySelector('.dialog-msg:last-child'); + if (lastEntry && !text.includes('<')) { + lastEntry.textContent = text; + } else if (lastEntry) { + lastEntry.innerHTML = text; + } + dialogText.scrollTop = dialogText.scrollHeight; + _finishMsg(msg?.onDone); return; } if (msgQueue.length > 1) { diff --git a/js/dom.js b/js/dom.js index a79ba0a..5574264 100644 --- a/js/dom.js +++ b/js/dom.js @@ -5,6 +5,7 @@ export const auditBtn = document.getElementById('audit-btn'); export const dialogText = document.getElementById('dialog-text'); export const dialogArrow = document.getElementById('dialog-arrow'); export const dialogHint = document.getElementById('dialog-hint'); +export const cancelBtn = document.getElementById('cancel-btn'); export const relayList = document.getElementById('relay-list'); export const sprite = document.getElementById('doctor-sprite'); export const spritePanel = document.getElementById('sprite-panel'); @@ -25,13 +26,19 @@ export function getRelayRow(id) { return document.getElementById(id); } /** * Returns chart action buttons from a chart container element. * @param {HTMLElement} chartEl - Chart container (e.g. cardContainer.firstElementChild) - * @returns {{ rebroadcast: Element|null, deleteKps: Element|null, unifyRelays: Element|null }} + * @returns {{ rebroadcast: Element|null, deleteKps: Element|null, unifyRelays: Element|null, operatingRoom: Element|null }} */ export function getChartActionButtons(chartEl) { - if (!chartEl) return { rebroadcast: null, deleteKps: null, unifyRelays: null }; + if (!chartEl) return { + rebroadcast: null, deleteKps: null, unifyRelays: null, + operatingRoom: null, deleteOrphanedKps: null, deleteKind4: null, + }; return { rebroadcast: chartEl.querySelector('[data-action="rebroadcast"]'), deleteKps: chartEl.querySelector('[data-action="delete-kps"]'), + deleteOrphanedKps: chartEl.querySelector('[data-action="delete-orphaned-kps"]'), unifyRelays: chartEl.querySelector('[data-action="unify-relays"]'), + deleteKind4: chartEl.querySelector('[data-action="delete-kind4"]'), + operatingRoom: chartEl.querySelector('[data-action="enter-operating-room"]'), }; } diff --git a/js/main.js b/js/main.js index 40a5180..f5d45c4 100644 --- a/js/main.js +++ b/js/main.js @@ -6,7 +6,7 @@ import { setSprite } from './sprite.js'; import { startAudit, resetAuditState } from './audit.js'; import { getDisplayName, speak } from './personalities.js'; import { removeScanBar } from './scan-bar.js'; -import { npubInput, nip07Btn, nextBtn, auditBtn, spritePanel, dialogSpeaker, spriteLabel, relayList } from './dom.js'; +import { npubInput, nip07Btn, nextBtn, auditBtn, cancelBtn, spritePanel, dialogSpeaker, spriteLabel, relayList } from './dom.js'; import { html } from './html.js'; if (isJeff) document.body.dataset.mode = 'jeff'; @@ -30,15 +30,16 @@ function initSpriteStyle() { initSpriteStyle(); -function updateNip07Visibility() { - nip07Btn.classList.toggle('hidden', !window.nostr); +function updateNip07State() { + nip07Btn.disabled = !window.nostr; + if (window.nostr) nip07Btn.classList.add('nip07-ready'); } -updateNip07Visibility(); +updateNip07State(); if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => setTimeout(updateNip07Visibility, 500)); + document.addEventListener('DOMContentLoaded', () => setTimeout(updateNip07State, 500)); } else { - setTimeout(updateNip07Visibility, 500); + setTimeout(updateNip07State, 500); } async function signInWithNip07() { @@ -72,6 +73,7 @@ function resetForNextPatient() { nip07Btn.addEventListener('click', () => { getAudio(); signInWithNip07(); }); auditBtn.addEventListener('click', () => { getAudio(); startAudit(); }); nextBtn.addEventListener('click', resetForNextPatient); +cancelBtn.addEventListener('click', () => { getAudio(); resetForNextPatient(); }); npubInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { getAudio(); startAudit(); } }); diff --git a/js/operate/autostage.js b/js/operate/autostage.js new file mode 100644 index 0000000..1245f17 --- /dev/null +++ b/js/operate/autostage.js @@ -0,0 +1,256 @@ +/** + * Auto-staging logic β€” maps audit prescriptions to OR staged changes. + * Analyzes the compiled audit result and pre-stages actionable fixes + * so the user enters the OR with a treatment plan already loaded. + */ + +import { stageAddRelay, stageRemoveRelay, stageToggleReadWrite, getRelayList } from './editor.js'; +import { validateRelayUrl } from '../relay-validation.js'; + +/** + * @typedef {Object} AutoStageResult + * @property {number} staged - Number of changes successfully auto-staged + * @property {string[]} actions - Human-readable descriptions of what was staged + * @property {string[]} suggestions - Things that need manual action (can't be auto-staged) + */ + +/** + * Analyze prescriptions and audit state to determine what can be auto-staged. + * Call this after initEditorState() in room.js. + * + * @param {Object} auditState - from getAuditState() + * @param {Object} compiledResult - from compileFindingsAndPrescriptions() stored on auditState + * @returns {AutoStageResult} + */ +export function autoStageFromPrescriptions(auditState, compiledResult) { + const actions = []; + const suggestions = []; + let staged = 0; + + // --- 1. Remove invalid relay URLs --- + staged += removeInvalidRelays(auditState, actions); + + // --- 2. Unify k3 and k10002 relay lists --- + if (compiledResult?.canUnifyRelays || auditState.canUnifyRelays) { + staged += unifyRelayLists(auditState, actions); + } + + // --- 3. Add missing inbox relays (k10050) from k10002 if k10050 is empty --- + if (compiledResult?.findings?.fail?.some(f => f.includes('kind 10050 contains no valid relay tags'))) { + staged += suggestInboxRelaysFromNip65(auditState, actions, suggestions); + } + + // --- 4. Handle orphaned KeyPackage relays --- + if (auditState.orphanedKpRelays?.length > 0) { + staged += stageOrphanedKpRelayFixes(auditState, actions, suggestions); + } + + // --- 5. NIP-65 read/write fixes --- + staged += fixNip65ReadWrite(auditState, compiledResult, actions); + + // --- 6. Suggest things that need manual/client action --- + addManualSuggestions(auditState, compiledResult, suggestions); + + return { staged, actions, suggestions }; +} + +/** + * Remove invalid relay URLs from all kinds. + * @returns {number} Count of staged removals + */ +function removeInvalidRelays(auditState, actions) { + let count = 0; + + // Check each kind for invalid URLs + for (const kind of [10002, 10050, 10051, 3]) { + const relays = getRelayList(kind); + for (const entry of relays) { + if (entry.removed || entry.staged) continue; + const { valid } = validateRelayUrl(entry.url); + if (!valid) { + const result = stageRemoveRelay(kind, entry.url); + if (result.ok) { + count++; + actions.push(`Remove invalid URL from k${kind}: ${entry.url}`); + } + } + } + } + + return count; +} + +/** + * Unify k3 and k10002 relay lists by adding missing relays to each. + * @returns {number} Count of staged additions + */ +function unifyRelayLists(auditState, actions) { + let count = 0; + + const k3Set = new Set((auditState.k3RelaySet || []).map(u => u.toLowerCase())); + const k10002Set = new Set((auditState.k10002RelaySet || []).map(u => u.toLowerCase())); + + // Add k3-only relays to k10002 + for (const url of auditState.k3RelaySet || []) { + if (!k10002Set.has(url.toLowerCase())) { + const result = stageAddRelay(10002, url, 'rw'); + if (result.ok) { + count++; + actions.push(`Add to k10002 (from k3): ${url}`); + } + } + } + + // Add k10002-only relays to k3 + for (const url of auditState.k10002RelaySet || []) { + if (!k3Set.has(url.toLowerCase())) { + const result = stageAddRelay(3, url); + if (result.ok) { + count++; + actions.push(`Add to k3 (from k10002): ${url}`); + } + } + } + + return count; +} + +/** + * Suggest inbox relays (k10050) from the user's NIP-65 read relays. + * @returns {number} Count of staged additions + */ +function suggestInboxRelaysFromNip65(auditState, actions, suggestions) { + let count = 0; + + const k10002Relays = getRelayList(10002); + const readRelays = k10002Relays.filter( + r => !r.removed && (r.readWrite === 'read' || r.readWrite === 'rw'), + ); + + if (readRelays.length > 0) { + // Add the first 3 read relays as inbox relays + for (const relay of readRelays.slice(0, 3)) { + const result = stageAddRelay(10050, relay.url); + if (result.ok) { + count++; + actions.push(`Add inbox relay (from NIP-65 read): ${relay.url}`); + } + } + } else { + suggestions.push('Add inbox relays (k10050) β€” no read relays found to suggest from'); + } + + return count; +} + +/** + * Handle orphaned KeyPackage relays β€” suggest adding them to k10051. + * @returns {number} Count of staged additions + */ +function stageOrphanedKpRelayFixes(auditState, actions, suggestions) { + let count = 0; + + for (const url of auditState.orphanedKpRelays) { + const result = stageAddRelay(10051, url); + if (result.ok) { + count++; + actions.push(`Add orphaned KP relay to k10051: ${url}`); + } + } + + if (count === 0 && auditState.orphanedKpRelays.length > 0) { + suggestions.push( + `${auditState.orphanedKpRelays.length} orphaned KP relay(s) already in k10051 β€” delete orphaned KPs from those relays instead`, + ); + } + + return count; +} + +/** + * Fix NIP-65 read/write issues by toggling relays that are read-only or write-only. + * Only acts when ALL relays are the same direction (no read or no write). + * @returns {number} Count of staged modifications + */ +function fixNip65ReadWrite(auditState, compiledResult, actions) { + let count = 0; + const findings = compiledResult?.findings; + if (!findings) return 0; + + const hasNoRead = findings.fail?.some(f => f.includes('NIP-65: no read relays')); + const hasNoWrite = findings.fail?.some(f => f.includes('NIP-65: no write relays')); + + if (!hasNoRead && !hasNoWrite) return 0; + + const k10002Relays = getRelayList(10002); + for (const entry of k10002Relays) { + if (entry.removed || entry.staged) continue; + + if (hasNoRead && entry.readWrite === 'write') { + // Make it rw so there's at least one read relay + stageToggleReadWrite(entry.url, 'rw'); + count++; + actions.push(`Set to R/W (was write-only): ${entry.url}`); + break; // Only need to fix one + } + + if (hasNoWrite && entry.readWrite === 'read') { + stageToggleReadWrite(entry.url, 'rw'); + count++; + actions.push(`Set to R/W (was read-only): ${entry.url}`); + break; + } + } + + return count; +} + +/** + * Add suggestions for things that require manual or client-side action. + */ +function addManualSuggestions(auditState, compiledResult, suggestions) { + const findings = compiledResult?.findings; + if (!findings) return; + + // k10051 missing entirely + if (findings.fail?.some(f => f.includes('No KeyPackage Relay List (kind 10051)'))) { + suggestions.push('Publish a KeyPackage Relay List (k10051) β€” add relays in the KEYPACKAGE RELAYS tray'); + } + + // k10050 missing entirely + if (findings.fail?.some(f => f.includes('No Inbox Relay List (kind 10050)'))) { + suggestions.push('Publish an Inbox Relay List (k10050) β€” add relays in the INBOX RELAYS tray'); + } + + // NIP-65 bloated + if (findings.warn?.some(f => f.includes('excessive relay count'))) { + suggestions.push('Consider removing some relays from k10002 β€” 10 or fewer is recommended'); + } + + // KeyPackage issues (client-side) + if (findings.fail?.some(f => f.includes('KP ') || f.includes('KeyPackage'))) { + suggestions.push('KeyPackage issues require your Marmot client to publish corrected KeyPackages'); + } + + // Stale profile/contacts + if (compiledResult?.canRebroadcast) { + suggestions.push('Profile/Contacts out of sync β€” use REBROADCAST from the chart, or re-audit after operating'); + } +} + +/** + * Format the auto-stage results into a briefing summary. + * @param {AutoStageResult} result + * @returns {{ actionSummary: string, suggestionSummary: string }} + */ +export function formatAutoStageSummary(result) { + const actionSummary = result.actions.length > 0 + ? result.actions.map(a => ` + ${a}`).join('\n') + : 'No changes auto-staged.'; + + const suggestionSummary = result.suggestions.length > 0 + ? result.suggestions.map(s => ` ? ${s}`).join('\n') + : ''; + + return { actionSummary, suggestionSummary }; +} diff --git a/js/operate/editor.js b/js/operate/editor.js new file mode 100644 index 0000000..f9c4432 --- /dev/null +++ b/js/operate/editor.js @@ -0,0 +1,422 @@ +/** + * Relay list editor β€” manages staged changes per event kind. + * Tracks add/remove/modify operations without publishing until confirmed. + */ + +import { html, escapeHtml } from '../html.js'; +import { validateRelayUrl, shortUrl } from '../relay-validation.js'; +import { formatLatency, healthScore } from './health.js'; + +/** + * @typedef {'add'|'remove'|'modify'} ChangeType + * + * @typedef {Object} StagedChange + * @property {ChangeType} type + * @property {string} url + * @property {string} [readWrite] - 'read'|'write'|'rw' (k10002 only) + * @property {string} [prevReadWrite] - Previous read/write state (for modify) + * + * @typedef {Object} RelayEntry + * @property {string} url + * @property {string} readWrite - 'read'|'write'|'rw'|'' (empty for non-10002 kinds) + * @property {boolean} staged - Whether this entry is a staged addition + * @property {boolean} removed - Whether this entry is staged for removal + */ + +/** Map of kind β†’ array of staged changes */ +const stagedChanges = new Map(); + +/** Map of kind β†’ array of current relay entries (from audit state) */ +const currentRelays = new Map(); + +/** + * Initialize the editor state from the audit result. + * @param {Object} auditState - from getAuditState() + */ +export function initEditorState(auditState) { + stagedChanges.clear(); + currentRelays.clear(); + + // Kind 10002 (NIP-65) β€” with read/write markers + const k10002Relays = []; + if (auditState.bestK10002?.tags) { + for (const t of auditState.bestK10002.tags) { + if (t[0] === 'r' && typeof t[1] === 'string' && t[1].trim()) { + const marker = t[2] || 'rw'; // 'read', 'write', or default 'rw' + k10002Relays.push({ url: t[1].trim(), readWrite: marker, staged: false, removed: false }); + } + } + } + currentRelays.set(10002, k10002Relays); + stagedChanges.set(10002, []); + + // Kind 10050 (Inbox Relays) + const k10050Relays = []; + if (auditState.best10050?.tags) { + for (const t of auditState.best10050.tags) { + if (t[0] === 'relay' && typeof t[1] === 'string' && t[1].trim()) { + k10050Relays.push({ url: t[1].trim(), readWrite: '', staged: false, removed: false }); + } + } + } + currentRelays.set(10050, k10050Relays); + stagedChanges.set(10050, []); + + // Kind 10051 (KeyPackage Relays) + const k10051Relays = []; + if (auditState.best10051?.tags) { + for (const t of auditState.best10051.tags) { + if (t[0] === 'relay' && typeof t[1] === 'string' && t[1].trim()) { + k10051Relays.push({ url: t[1].trim(), readWrite: '', staged: false, removed: false }); + } + } + } + currentRelays.set(10051, k10051Relays); + stagedChanges.set(10051, []); + + // Kind 3 (Contacts relay hints) + const k3Relays = []; + if (auditState.bestK3?.tags) { + for (const t of auditState.bestK3.tags) { + if (t[0] === 'relay' && typeof t[1] === 'string' && t[1].trim()) { + k3Relays.push({ url: t[1].trim(), readWrite: '', staged: false, removed: false }); + } + } + } + currentRelays.set(3, k3Relays); + stagedChanges.set(3, []); +} + +/** + * Stage adding a relay to a kind's list. + * @param {number} kind + * @param {string} url + * @param {string} [readWrite='rw'] - For k10002 only + * @returns {{ ok: boolean, reason?: string }} + */ +export function stageAddRelay(kind, url, readWrite = 'rw') { + const trimmed = url.trim(); + const validation = validateRelayUrl(trimmed); + if (!validation.valid) return { ok: false, reason: validation.reason }; + + const relays = currentRelays.get(kind) || []; + const changes = stagedChanges.get(kind) || []; + + // Check duplicates (including already-staged adds) + const exists = relays.some(r => r.url.toLowerCase() === trimmed.toLowerCase() && !r.removed); + const alreadyStaged = changes.some(c => c.type === 'add' && c.url.toLowerCase() === trimmed.toLowerCase()); + if (exists || alreadyStaged) return { ok: false, reason: 'already in list' }; + + // If previously removed, un-remove instead of adding + const removeIdx = changes.findIndex(c => c.type === 'remove' && c.url.toLowerCase() === trimmed.toLowerCase()); + if (removeIdx !== -1) { + changes.splice(removeIdx, 1); + const entry = relays.find(r => r.url.toLowerCase() === trimmed.toLowerCase()); + if (entry) entry.removed = false; + return { ok: true }; + } + + changes.push({ type: 'add', url: trimmed, readWrite: kind === 10002 ? readWrite : '' }); + relays.push({ url: trimmed, readWrite: kind === 10002 ? readWrite : '', staged: true, removed: false }); + stagedChanges.set(kind, changes); + currentRelays.set(kind, relays); + return { ok: true }; +} + +/** + * Stage removing a relay from a kind's list. + * @param {number} kind + * @param {string} url + * @returns {{ ok: boolean, reason?: string }} + */ +export function stageRemoveRelay(kind, url) { + const trimmed = url.trim(); + const relays = currentRelays.get(kind) || []; + const changes = stagedChanges.get(kind) || []; + + // If it was a staged add, just remove the staged add + const addIdx = changes.findIndex(c => c.type === 'add' && c.url.toLowerCase() === trimmed.toLowerCase()); + if (addIdx !== -1) { + changes.splice(addIdx, 1); + const entryIdx = relays.findIndex(r => r.url.toLowerCase() === trimmed.toLowerCase() && r.staged); + if (entryIdx !== -1) relays.splice(entryIdx, 1); + return { ok: true }; + } + + const entry = relays.find(r => r.url.toLowerCase() === trimmed.toLowerCase()); + if (!entry) return { ok: false, reason: 'not in list' }; + if (entry.removed) return { ok: false, reason: 'already staged for removal' }; + + entry.removed = true; + changes.push({ type: 'remove', url: trimmed }); + stagedChanges.set(kind, changes); + return { ok: true }; +} + +/** + * Stage toggling read/write marker for a k10002 relay. + * @param {string} url + * @param {string} newReadWrite - 'read'|'write'|'rw' + * @returns {{ ok: boolean, reason?: string }} + */ +export function stageToggleReadWrite(url, newReadWrite) { + const trimmed = url.trim(); + const relays = currentRelays.get(10002) || []; + const changes = stagedChanges.get(10002) || []; + + const entry = relays.find(r => r.url.toLowerCase() === trimmed.toLowerCase() && !r.removed); + if (!entry) return { ok: false, reason: 'not in list' }; + if (entry.readWrite === newReadWrite) return { ok: true }; // no change + + const prevReadWrite = entry.readWrite; + entry.readWrite = newReadWrite; + + // Remove any existing modify change for this URL + const existingIdx = changes.findIndex(c => c.type === 'modify' && c.url.toLowerCase() === trimmed.toLowerCase()); + if (existingIdx !== -1) changes.splice(existingIdx, 1); + + changes.push({ type: 'modify', url: trimmed, readWrite: newReadWrite, prevReadWrite }); + stagedChanges.set(10002, changes); + return { ok: true }; +} + +/** + * Get the current relay list for a kind (including staged changes reflected). + * @param {number} kind + * @returns {RelayEntry[]} + */ +export function getRelayList(kind) { + return currentRelays.get(kind) || []; +} + +/** + * Get staged changes for a kind. + * @param {number} kind + * @returns {StagedChange[]} + */ +export function getStagedChanges(kind) { + return stagedChanges.get(kind) || []; +} + +/** + * Check if there are any staged changes across all kinds. + * @returns {boolean} + */ +export function hasAnyStagedChanges() { + for (const changes of stagedChanges.values()) { + if (changes.length > 0) return true; + } + return false; +} + +/** + * Get total count of staged changes. + * @returns {number} + */ +export function totalStagedChanges() { + let count = 0; + for (const changes of stagedChanges.values()) { + count += changes.length; + } + return count; +} + +/** + * Get the final relay list for a kind (after applying staged changes). + * Removes entries marked for removal, includes staged additions. + * @param {number} kind + * @returns {{ url: string, readWrite: string }[]} + */ +export function getFinalRelayList(kind) { + const relays = currentRelays.get(kind) || []; + return relays + .filter(r => !r.removed) + .map(r => ({ url: r.url, readWrite: r.readWrite })); +} + +/** + * Clear all staged changes without applying them. + */ +export function clearAllChanges() { + for (const kind of stagedChanges.keys()) { + stagedChanges.set(kind, []); + } + // Re-init from currentRelays by removing staged entries and un-removing removed entries + for (const [kind, relays] of currentRelays.entries()) { + const cleaned = relays.filter(r => !r.staged); + for (const r of cleaned) r.removed = false; + currentRelays.set(kind, cleaned); + } +} + +// ─── RENDERING ─────────────────────────────────────────────────────────────── + +const KIND_LABELS = { + 10002: { title: 'NIP-65 RELAY LIST', tag: 'k10002', icon: 'πŸ“‘' }, + 10050: { title: 'INBOX RELAYS', tag: 'k10050', icon: 'πŸ“¨' }, + 10051: { title: 'KEYPACKAGE RELAYS', tag: 'k10051', icon: 'πŸ”‘' }, + 3: { title: 'CONTACTS RELAY HINTS', tag: 'k3', icon: 'πŸ‘₯' }, +}; + +/** + * Render a relay tray (collapsible section) for a single kind. + * @param {number} kind + * @param {Map} [healthResults] + * @returns {string} HTML string + */ +export function renderRelayTray(kind, healthResults) { + const label = KIND_LABELS[kind] || { title: `KIND ${kind}`, tag: `k${kind}`, icon: 'β—ˆ' }; + const relays = getRelayList(kind); + const changes = getStagedChanges(kind); + const changeCount = changes.length; + + const relayRows = relays.map((entry) => { + const health = healthResults?.get(entry.url); + const hp = health ? healthScore(health) : null; + const latencyStr = health + ? `C:${formatLatency(health.connectMs)} R:${formatLatency(health.readMs)}` + : ''; + const statusCls = entry.removed ? 'or-removed' : entry.staged ? 'or-staged' : ''; + const healthCls = health + ? `or-health-${health.status}` + : ''; + const changeIcon = entry.removed ? 'βˆ’' : entry.staged ? '+' : ''; + const changeCls = entry.removed ? 'or-change-remove' : entry.staged ? 'or-change-add' : ''; + + let rwToggles = ''; + if (kind === 10002 && !entry.removed) { + const isRead = entry.readWrite === 'read' || entry.readWrite === 'rw'; + const isWrite = entry.readWrite === 'write' || entry.readWrite === 'rw'; + rwToggles = html` + + + + + `; + } + + const removeBtn = entry.removed + ? html`` + : html``; + + return html` +
+ ${changeIcon} + + ${escapeHtml(shortUrl(entry.url))} + ${rwToggles} + ${latencyStr} + ${hp !== null ? html` + + + + ` : ''} + ${removeBtn} +
+ `; + }); + + const stageBadge = changeCount > 0 + ? html`${changeCount} STAGED` + : ''; + + return html` +
+
+ ${label.icon} + ${label.title} + ${label.tag} + ${relays.filter(r => !r.removed).length} relay(s) + ${stageBadge} + β–Ύ +
+
+ ${relayRows.length > 0 ? relayRows : html`
No relays configured
`} +
+ + +
+ +
+
+ `; +} + +/** + * Render the full Operating Room editor panel. + * @param {Map} [healthResults] + * @returns {string} HTML string + */ +export function renderEditorPanel(healthResults) { + const kinds = [10002, 10050, 10051, 3]; + const trays = kinds.map(k => renderRelayTray(k, healthResults)); + const totalChanges = totalStagedChanges(); + + return html` +
+
+ β—ˆ OPERATING ROOM + RELAY MANAGEMENT +
+ ${trays} +
+ ${totalChanges > 0 ? html` +
+ ${totalChanges} change(s) staged + +
+ + ` : html` +
No changes staged β€” add or remove relays above
+ `} + +
+
+ `; +} + +/** + * Render the staged changes summary (diff preview). + * @returns {string} HTML string + */ +export function renderStagedSummary() { + const kinds = [10002, 10050, 10051, 3]; + const sections = []; + + for (const kind of kinds) { + const changes = getStagedChanges(kind); + if (changes.length === 0) continue; + + const label = KIND_LABELS[kind] || { title: `KIND ${kind}` }; + const rows = changes.map((c) => { + if (c.type === 'add') { + return html`
+ ${escapeHtml(shortUrl(c.url))}${c.readWrite ? ` [${c.readWrite.toUpperCase()}]` : ''}
`; + } + if (c.type === 'remove') { + return html`
βˆ’ ${escapeHtml(shortUrl(c.url))}
`; + } + if (c.type === 'modify') { + return html`
~ ${escapeHtml(shortUrl(c.url))} [${(c.prevReadWrite || '').toUpperCase()} β†’ ${(c.readWrite || '').toUpperCase()}]
`; + } + return ''; + }); + + sections.push(html` +
+
${label.title}
+ ${rows} +
+ `); + } + + return sections.length > 0 + ? html`
${sections}
` + : ''; +} diff --git a/js/operate/guide.js b/js/operate/guide.js new file mode 100644 index 0000000..f70e76d --- /dev/null +++ b/js/operate/guide.js @@ -0,0 +1,380 @@ +/** + * Guided treatment flow β€” step-by-step prescription walker. + * Presents prescriptions one at a time with doctor recommendations, + * highlights the relevant tray, and lets the user advance or skip. + */ + +import { html, escapeHtml } from '../html.js'; +import { speak } from '../personalities.js'; + +/** + * @typedef {Object} GuideStep + * @property {number} index - Step number (0-based) + * @property {string} prescription - The prescription text + * @property {string|null} recommendation - Doctor's recommendation for this step + * @property {number|null} targetKind - Which kind tray to highlight (10002, 10050, 10051, 3), or null + * @property {boolean} isActionable - Whether the OR can stage a fix for this + * @property {boolean} isClientSide - Whether this requires client/external action + * @property {string|null} fixAction - data-action value for the FIX IT button, or null + */ + +let steps = []; +let currentStep = -1; +let isGuided = false; + +/** + * Whether guided mode is active. + * @returns {boolean} + */ +export function isGuidedMode() { + return isGuided; +} + +/** + * Get the current step index. + * @returns {number} + */ +export function getCurrentStepIndex() { + return currentStep; +} + +/** + * Get the current step, or null if not in guided mode. + * @returns {GuideStep|null} + */ +export function getCurrentStep() { + if (!isGuided || currentStep < 0 || currentStep >= steps.length) return null; + return steps[currentStep]; +} + +/** + * Get total step count. + * @returns {number} + */ +export function getTotalSteps() { + return steps.length; +} + +/** + * Initialize the guided flow from prescriptions. + * @param {string[]} prescriptions - From compileFindingsAndPrescriptions + * @param {Object} _findings - The findings object with pass/warn/fail arrays (reserved for future step enrichment) + * @returns {number} Total steps + */ +export function initGuide(prescriptions, _findings) { + steps = prescriptions.map((rx, index) => ({ + index, + prescription: rx, + recommendation: getRecommendationForPrescription(rx), + targetKind: getTargetKindForPrescription(rx), + isActionable: isActionablePrescription(rx), + isClientSide: isClientSidePrescription(rx), + fixAction: getFixActionForPrescription(rx), + })); + currentStep = steps.length > 0 ? 0 : -1; + isGuided = true; + return steps.length; +} + +/** + * Advance to the next step. + * @returns {GuideStep|null} The next step, or null if done + */ +export function nextStep() { + if (!isGuided) return null; + currentStep++; + if (currentStep >= steps.length) { + return null; + } + return steps[currentStep]; +} + +/** + * Skip the current step and advance. + * @returns {GuideStep|null} The next step, or null if done + */ +export function skipStep() { + return nextStep(); +} + +/** + * Exit guided mode. + */ +export function exitGuide() { + isGuided = false; + currentStep = -1; + steps = []; +} + +/** + * Switch to manual (free-form) mode without losing position. + */ +export function switchToManual() { + isGuided = false; +} + +/** + * Resume guided mode from the current position. + */ +export function resumeGuide() { + if (steps.length > 0) { + isGuided = true; + if (currentStep < 0) currentStep = 0; + } +} + +/** + * Render the guided step card UI. + * @param {GuideStep} step + * @returns {string} HTML string + */ +export function renderGuideCard(step) { + if (!step) return ''; + + const progress = `${step.index + 1} / ${steps.length}`; + const kindLabel = step.targetKind ? `k${step.targetKind}` : ''; + const actionableTag = step.isClientSide + ? 'CLIENT' + : step.isActionable + ? 'FIXABLE' + : 'MANUAL'; + + const recommendation = step.recommendation + ? html`
${step.recommendation}
` + : ''; + + const doctorNote = speak('guideStepNote', { step: step.index + 1, total: steps.length }) + || ''; + + const requiresNip07 = !window.nostr; + const fixBtn = step.fixAction + ? html` + + ` + : ''; + + return html` +
+
+ πŸ“‹ + TREATMENT STEP + ${progress} + ${kindLabel ? html`${kindLabel}` : ''} +
+
+
+ ${actionableTag} + ${escapeHtml(step.prescription)} +
+ ${recommendation} + ${doctorNote ? html`
${doctorNote}
` : ''} +
+
+ ${fixBtn} + + + +
+
+ `; +} + +/** + * Render the guide mode toggle (shown in manual mode to resume). + * @returns {string} HTML string + */ +export function renderGuideResumeBar() { + if (steps.length === 0) return ''; + const remaining = steps.length - Math.max(0, currentStep); + return html` +
+ +
+ `; +} + +// ─── PRESCRIPTION ANALYSIS ───────────────────────────────────────────────── + +const KIND_PATTERNS = { + 10002: ['k10002', 'kind 10002', 'NIP-65', 'nip-65', 'read relay', 'write relay'], + 10050: ['k10050', 'kind 10050', 'inbox', 'giftwrap', 'DM inbox'], + 10051: ['k10051', 'kind 10051', 'KeyPackage Relay', 'keypackage relay'], + 3: ['k3', 'kind 3', 'contacts', 'Contacts'], +}; + +/** + * Determine which kind tray a prescription targets. + * @param {string} rx + * @returns {number|null} + */ +function getTargetKindForPrescription(rx) { + const lower = rx.toLowerCase(); + + // Check for multi-kind prescriptions first + const multiKind = ['k3 / k10002', 'k3/k10002', 'unify'].some(p => lower.includes(p)); + if (multiKind) return 10002; // Primary target is k10002 + + for (const [kind, patterns] of Object.entries(KIND_PATTERNS)) { + if (patterns.some(p => lower.includes(p.toLowerCase()))) { + return Number(kind); + } + } + + // KeyPackage-related + if (lower.includes('keypackage') || lower.includes('kind 443') || lower.includes('mls_')) { + return 10051; + } + + return null; +} + +/** + * Check if a prescription can be addressed by OR staging. + * @param {string} rx + * @returns {boolean} + */ +function isActionablePrescription(rx) { + const lower = rx.toLowerCase(); + const actionablePatterns = [ + 'fix invalid relay', + 'add relay tags', + 'unify', + 'add at least one read relay', + 'add at least one write relay', + 'reduce kind 10002', + 'publish a keypackage relay list', + 'publish an inbox relay list', + 'publish a dm inbox', + 'delete orphaned', + ]; + return actionablePatterns.some(p => lower.includes(p)); +} + +/** + * Check if a prescription requires client-side action (not OR fixable). + * @param {string} rx + * @returns {boolean} + */ +function isClientSidePrescription(rx) { + const lower = rx.toLowerCase(); + // NIP-04 migration is now actionable via deleteDeprecatedKind4 + if (lower.includes('migrate from nip-04')) return false; + const clientPatterns = [ + 'encoding', + '0xf2ee', + '0x000a', + 'ciphersuite', + 'mls_extensions', + 'keypackagebundle', + 'keypackageref', + 'rotate mls', + 'default extensions', + 'stop publishing kind 2', + 'i tag', + 'delete events', + ]; + return clientPatterns.some(p => lower.includes(p)); +} + +/** + * Map a prescription to its fix action (data-action value for the FIX IT button). + * Returns null if no automatic fix is available. + * @param {string} rx + * @returns {string|null} + */ +function getFixActionForPrescription(rx) { + const lower = rx.toLowerCase(); + if (lower.includes('rebroadcast') && lower.includes('profile')) return 'rebroadcast'; + if (lower.includes('delete orphaned keypackages')) return 'delete-orphaned-kps'; + if (lower.includes('unify your k3 and k10002')) return 'unify-relays'; + if (lower.includes('migrate from nip-04') || (lower.includes('nip-04') && lower.includes('kind 4'))) return 'delete-kind4'; + if (lower.includes('delete events') && lower.includes('keypackage')) return 'delete-kps'; + return null; +} + +/** + * Get a recommendation explanation for a prescription. + * These are the factual explanations; personality wrapper comes from speak(). + * @param {string} rx + * @returns {string|null} + */ +function getRecommendationForPrescription(rx) { + const lower = rx.toLowerCase(); + + if (lower.includes('fix invalid relay') && lower.includes('k3 / k10002')) { + return 'Invalid relay URLs prevent clients from discovering where to find your events. Remove the broken URLs and add correct wss:// addresses.'; + } + if (lower.includes('rebroadcast')) { + return 'Your profile or contacts are stale on some relays. Rebroadcasting pushes the latest version to all relays so clients see consistent data.'; + } + if (lower.includes('publish a keypackage relay list') || (lower.includes('kind 10051') && lower.includes('publish'))) { + return 'Kind 10051 tells other users where to find your KeyPackages. Without it, nobody can invite you to Marmot groups.'; + } + if (lower.includes('add relay tags') && lower.includes('10051')) { + return 'Your k10051 event exists but has no relay URLs. Add at least one relay where your KeyPackages are published.'; + } + if (lower.includes('publish an inbox relay list') || lower.includes('publish a dm inbox') || (lower.includes('kind 10050') && lower.includes('publish'))) { + return 'Kind 10050 tells clients where to deliver giftwrapped messages to you. Without it, encrypted DMs and Marmot invites cannot reach you.'; + } + if (lower.includes('add relay tags') && lower.includes('10050')) { + return 'Your k10050 event exists but has no relay URLs. Add at least one relay to receive giftwrapped messages.'; + } + if (lower.includes('unify')) { + return 'Your Contacts (k3) and NIP-65 (k10002) relay lists advertise different relays. Different clients check different lists, so having them agree means better discoverability.'; + } + if (lower.includes('no read relays')) { + return 'Without read relays in NIP-65, clients don\'t know where to deliver replies to you. At least one relay needs the read marker.'; + } + if (lower.includes('no write relays')) { + return 'Without write relays in NIP-65, clients don\'t know where you publish events. At least one relay needs the write marker.'; + } + if (lower.includes('excessive relay count') || lower.includes('reduce kind 10002')) { + return 'More than 10 relays in NIP-65 causes performance issues for clients. Each client must check all of them. Trim to your most reliable 5-8 relays.'; + } + if (lower.includes('encoding')) { + return 'The KeyPackage encoding tag must be "base64". MIP-00 requires base64-encoded KeyPackageBundle content.'; + } + if (lower.includes('0xf2ee')) { + return 'The marmot_group_data extension (0xf2ee) carries group metadata. Without it, Marmot clients cannot parse your KeyPackage.'; + } + if (lower.includes('0x000a')) { + return 'The last_resort extension (0x000a) marks a KeyPackage as always available. MIP-00 requires this for the primary KeyPackage.'; + } + if (lower.includes('ciphersuite')) { + return 'The mls_ciphersuite tag identifies which cryptographic algorithms your KeyPackage uses. It must be in the range 0x0001-0x0007.'; + } + if (lower.includes('orphaned keypackages') || lower.includes('delete orphaned')) { + return 'KeyPackages on relays not listed in your k10051 are invisible to other users. Either add those relays to k10051 or delete the orphaned KeyPackages.'; + } + if (lower.includes('blossom')) { + return 'A Blossom server list (k10063) tells clients where to upload media on your behalf. Without it, media attachments may not work.'; + } + if (lower.includes('nip-04') || lower.includes('kind 4')) { + return 'NIP-04 (kind 4) DMs expose metadata (who you\'re talking to and when). NIP-17 and Marmot both encrypt metadata. Migrate for better privacy.'; + } + if (lower.includes('kind 2')) { + return 'Kind 2 (Recommend Relay) is deprecated. Use NIP-65 (kind 10002) for relay advertisement instead.'; + } + if (lower.includes('publish at least one keypackage')) { + return 'No KeyPackages found on your advertised relays. Your Marmot client needs to publish at least one KeyPackage (kind 443) so others can invite you to groups.'; + } + if (lower.includes('fix i tag') || lower.includes('i tag')) { + return 'The i tag must contain a hex-encoded KeyPackageRef matching the ciphersuite hash length. It uniquely identifies the KeyPackage for deduplication.'; + } + if (lower.includes('default extensions')) { + return 'Default MLS extensions (0x0001-0x0005) are implicit and must not be listed in mls_extensions. Only custom extensions belong there.'; + } + if (lower.includes('relays tag')) { + return 'The relays tag in KeyPackage events tells clients where to deliver Welcome messages. It must contain valid wss:// URLs.'; + } + + return null; +} diff --git a/js/operate/health.js b/js/operate/health.js new file mode 100644 index 0000000..19013e9 --- /dev/null +++ b/js/operate/health.js @@ -0,0 +1,251 @@ +/** + * Relay health diagnostics. + * Tests: WebSocket connectivity + latency, NIP-11 info, read latency (REQβ†’EOSE). + */ + +import { fetchNip11, formatNip11Summary } from './nip11.js'; + +const WS_CONNECT_TIMEOUT_MS = 8000; +const READ_TEST_TIMEOUT_MS = 6000; + +/** + * @typedef {Object} HealthResult + * @property {string} url - Relay URL + * @property {'ok'|'warn'|'dead'} status - Overall health status + * @property {number|null} connectMs - WS connect latency (ms) or null if failed + * @property {number|null} readMs - REQβ†’EOSE latency (ms) or null if not tested + * @property {Object|null} nip11 - NIP-11 info doc or null + * @property {string} nip11Summary - Short human-readable NIP-11 summary + * @property {string|null} error - Error message if connect failed + */ + +/** + * Test WebSocket connectivity and measure connect latency. + * @param {string} relayUrl + * @returns {Promise<{ connectMs: number|null, error: string|null, ws: WebSocket|null }>} + */ +function testWsConnect(relayUrl) { + return new Promise((resolve) => { + const start = performance.now(); + let resolved = false; + + const done = (result) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + resolve(result); + }; + + let ws; + try { + ws = new WebSocket(relayUrl); + } catch (e) { + done({ connectMs: null, error: e?.message || 'WebSocket constructor failed', ws: null }); + return; + } + + const timer = setTimeout(() => { + try { ws.close(); } catch { /* ignore */ } + done({ connectMs: null, error: 'timeout', ws: null }); + }, WS_CONNECT_TIMEOUT_MS); + + ws.onopen = () => { + const elapsed = Math.round(performance.now() - start); + done({ connectMs: elapsed, error: null, ws }); + }; + + ws.onerror = () => { + try { ws.close(); } catch { /* ignore */ } + done({ connectMs: null, error: 'connection failed', ws: null }); + }; + }); +} + +/** + * Test read latency by sending a REQ and measuring time to EOSE. + * Uses a filter guaranteed to return zero events (random author). + * @param {WebSocket} ws - Open WebSocket connection + * @returns {Promise<{ readMs: number|null, error: string|null }>} + */ +function testReadLatency(ws) { + return new Promise((resolve) => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + resolve({ readMs: null, error: 'not connected' }); + return; + } + + const subId = '_health_' + Math.random().toString(36).slice(2, 8); + const start = performance.now(); + let resolved = false; + + const done = (result) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + ws.removeEventListener('message', onMessage); + // Send CLOSE for the subscription + try { ws.send(JSON.stringify(['CLOSE', subId])); } catch { /* ignore */ } + resolve(result); + }; + + const timer = setTimeout(() => { + done({ readMs: null, error: 'timeout' }); + }, READ_TEST_TIMEOUT_MS); + + const onMessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (Array.isArray(msg) && msg[1] === subId && msg[0] === 'EOSE') { + const elapsed = Math.round(performance.now() - start); + done({ readMs: elapsed, error: null }); + } + } catch { /* ignore parse errors */ } + }; + + ws.addEventListener('message', onMessage); + + // Random hex author that won't match anything + const fakeAuthor = 'ff'.repeat(32); + const req = JSON.stringify(['REQ', subId, { authors: [fakeAuthor], kinds: [0], limit: 1 }]); + try { + ws.send(req); + } catch (e) { + done({ readMs: null, error: e?.message || 'send failed' }); + } + }); +} + +/** + * Run full health diagnostics on a single relay. + * @param {string} relayUrl + * @param {function} [onProgress] - Optional callback: (phase: string) => void + * @returns {Promise} + */ +export async function checkRelayHealth(relayUrl, onProgress) { + const result = { + url: relayUrl, + status: 'dead', + connectMs: null, + readMs: null, + nip11: null, + nip11Summary: '', + error: null, + }; + + // Phase 1: WS connect + NIP-11 in parallel + onProgress?.('connecting'); + const [wsResult, nip11Result] = await Promise.all([ + testWsConnect(relayUrl), + fetchNip11(relayUrl), + ]); + + result.nip11 = nip11Result; + result.nip11Summary = formatNip11Summary(nip11Result); + + if (wsResult.error) { + result.error = wsResult.error; + result.status = 'dead'; + return result; + } + + result.connectMs = wsResult.connectMs; + + // Phase 2: Read latency test + onProgress?.('testing'); + const readResult = await testReadLatency(wsResult.ws); + result.readMs = readResult.readMs; + + // Clean up WebSocket + try { wsResult.ws.close(); } catch { /* ignore */ } + + // Determine status + const totalLatency = (result.connectMs || 0) + (result.readMs || 0); + if (result.connectMs === null) { + result.status = 'dead'; + } else if (totalLatency > 3000 || result.readMs === null) { + result.status = 'warn'; + } else if (totalLatency > 1500) { + result.status = 'warn'; + } else { + result.status = 'ok'; + } + + return result; +} + +/** + * Run health checks on multiple relays with progress callbacks. + * @param {string[]} relayUrls + * @param {function} [onRelayProgress] - (url: string, phase: string) => void + * @param {function} [onRelayDone] - (result: HealthResult) => void + * @returns {Promise>} + */ +export async function checkAllRelaysHealth(relayUrls, onRelayProgress, onRelayDone) { + const results = new Map(); + + // Run checks in parallel batches of 4 to avoid overwhelming the browser + const BATCH_SIZE = 4; + for (let i = 0; i < relayUrls.length; i += BATCH_SIZE) { + const batch = relayUrls.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (url) => { + const result = await checkRelayHealth( + url, + (phase) => onRelayProgress?.(url, phase), + ); + onRelayDone?.(result); + return result; + }), + ); + for (const r of batchResults) { + results.set(r.url, r); + } + } + + return results; +} + +/** + * Format latency for display. + * @param {number|null} ms + * @returns {string} + */ +export function formatLatency(ms) { + if (ms === null) return '---'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Compute a health score (0–100) from a HealthResult. + * @param {HealthResult} result + * @returns {number} + */ +export function healthScore(result) { + if (result.status === 'dead') return 0; + + let score = 100; + + // Connect latency penalty + if (result.connectMs !== null) { + if (result.connectMs > 2000) score -= 40; + else if (result.connectMs > 1000) score -= 25; + else if (result.connectMs > 500) score -= 10; + } else { + score -= 50; + } + + // Read latency penalty + if (result.readMs !== null) { + if (result.readMs > 2000) score -= 30; + else if (result.readMs > 1000) score -= 15; + else if (result.readMs > 500) score -= 5; + } else { + score -= 20; + } + + // NIP-11 bonus/penalty + if (result.nip11?.error) score -= 5; + + return Math.max(0, Math.min(100, score)); +} diff --git a/js/operate/nip11.js b/js/operate/nip11.js new file mode 100644 index 0000000..fe4160e --- /dev/null +++ b/js/operate/nip11.js @@ -0,0 +1,94 @@ +/** + * NIP-11 relay information document fetcher. + * Converts wss:// relay URLs to https:// and fetches the info doc. + */ + +const NIP11_TIMEOUT_MS = 5000; + +/** + * Convert a WebSocket relay URL to its HTTP(S) equivalent for NIP-11. + * @param {string} wsUrl - e.g. "wss://relay.damus.io" + * @returns {string} e.g. "https://relay.damus.io" + */ +function wsToHttp(wsUrl) { + return wsUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://'); +} + +/** + * Fetch NIP-11 info document from a relay. + * @param {string} relayUrl - WebSocket relay URL (wss://…) + * @returns {Promise<{ + * name?: string, + * description?: string, + * pubkey?: string, + * contact?: string, + * supported_nips?: number[], + * software?: string, + * version?: string, + * limitation?: { + * max_message_length?: number, + * max_subscriptions?: number, + * max_filters?: number, + * max_limit?: number, + * max_event_tags?: number, + * max_content_length?: number, + * min_pow_difficulty?: number, + * auth_required?: boolean, + * payment_required?: boolean, + * }, + * error?: string, + * }>} + */ +export async function fetchNip11(relayUrl) { + const httpUrl = wsToHttp(relayUrl); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), NIP11_TIMEOUT_MS); + + const resp = await fetch(httpUrl, { + headers: { 'Accept': 'application/nostr+json' }, + signal: controller.signal, + }); + clearTimeout(timer); + + if (!resp.ok) { + return { error: `HTTP ${resp.status}` }; + } + + const contentType = resp.headers.get('content-type') || ''; + if (!contentType.includes('json')) { + return { error: `Unexpected content-type: ${contentType.slice(0, 40)}` }; + } + + const info = await resp.json(); + return info; + } catch (e) { + if (e.name === 'AbortError') { + return { error: 'timeout' }; + } + return { error: e?.message || 'fetch failed' }; + } +} + +/** + * Format NIP-11 info for display as a short summary string. + * @param {Object} info - NIP-11 response object + * @returns {string} Human-readable summary + */ +export function formatNip11Summary(info) { + if (!info || info.error) return info?.error || 'unknown'; + + const parts = []; + if (info.name) parts.push(info.name); + if (info.software) { + const sw = info.software.replace(/^https?:\/\//, '').replace(/\.git$/, ''); + parts.push(info.version ? `${sw} v${info.version}` : sw); + } + if (info.supported_nips?.length > 0) { + parts.push(`NIPs: ${info.supported_nips.slice(0, 8).join(',')}${info.supported_nips.length > 8 ? '…' : ''}`); + } + if (info.limitation?.auth_required) parts.push('AUTH required'); + if (info.limitation?.payment_required) parts.push('PAID'); + + return parts.join(' Β· ') || 'no info'; +} diff --git a/js/operate/procedures.js b/js/operate/procedures.js new file mode 100644 index 0000000..490552c --- /dev/null +++ b/js/operate/procedures.js @@ -0,0 +1,267 @@ +/** + * Surgical procedures β€” event construction, NIP-07 signing, and publishing. + * Each procedure creates Nostr events from the editor's final state, + * signs via NIP-07, and publishes to relays. + */ + +import { SimplePool } from 'https://esm.sh/nostr-tools'; +import { getAuditState } from '../audit.js'; +import { getFinalRelayList, getStagedChanges } from './editor.js'; + +const PUBLISH_TIMEOUT_MS = 15_000; + +/** + * @typedef {Object} Procedure + * @property {string} name - Human-readable procedure name + * @property {number} kind - Nostr event kind + * @property {string} description - What this procedure does + * @property {Object} unsignedEvent - The unsigned Nostr event to sign + */ + +/** + * @typedef {Object} ProcedureResult + * @property {string} name + * @property {boolean} ok + * @property {number} succeeded - Number of relays that accepted + * @property {number} failed - Number of relays that rejected + * @property {{ relay: string, ok: boolean }[]} relayResults + */ + +/** + * Build unsigned kind 10002 (NIP-65) event from editor state. + * @param {string} pubkey + * @returns {Object|null} + */ +function buildK10002Event(pubkey) { + const relays = getFinalRelayList(10002); + if (relays.length === 0) return null; + + const tags = relays.map(({ url, readWrite }) => { + if (readWrite === 'read') return ['r', url, 'read']; + if (readWrite === 'write') return ['r', url, 'write']; + return ['r', url]; // 'rw' or default β€” no third element means both + }); + + return { + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + tags, + content: '', + pubkey, + }; +} + +/** + * Build unsigned kind 10050 (Inbox Relays) event from editor state. + * @param {string} pubkey + * @returns {Object|null} + */ +function buildK10050Event(pubkey) { + const relays = getFinalRelayList(10050); + if (relays.length === 0) return null; + + return { + kind: 10050, + created_at: Math.floor(Date.now() / 1000), + tags: relays.map(({ url }) => ['relay', url]), + content: '', + pubkey, + }; +} + +/** + * Build unsigned kind 10051 (KeyPackage Relays) event from editor state. + * @param {string} pubkey + * @returns {Object|null} + */ +function buildK10051Event(pubkey) { + const relays = getFinalRelayList(10051); + if (relays.length === 0) return null; + + return { + kind: 10051, + created_at: Math.floor(Date.now() / 1000), + tags: relays.map(({ url }) => ['relay', url]), + content: '', + pubkey, + }; +} + +/** + * Build unsigned kind 3 (Contacts) event from editor state. + * Preserves all existing 'p' tags and content from the current k3 event. + * Only updates 'relay' tags. + * @param {string} pubkey + * @returns {Object|null} + */ +function buildK3Event(pubkey) { + const state = getAuditState(); + const relays = getFinalRelayList(3); + if (relays.length === 0 && !state?.bestK3) return null; + + // Preserve all non-relay tags (p tags, etc.) from existing k3 + const preservedTags = []; + if (state?.bestK3?.tags) { + for (const t of state.bestK3.tags) { + if (Array.isArray(t) && t[0] !== 'relay') { + preservedTags.push(t); + } + } + } + + const relayTags = relays.map(({ url }) => ['relay', url]); + + return { + kind: 3, + created_at: Math.floor(Date.now() / 1000), + tags: [...preservedTags, ...relayTags], + content: state?.bestK3?.content || '', + pubkey, + }; +} + +/** + * Determine which procedures need to run based on staged changes. + * @returns {Procedure[]} + */ +export function planProcedures() { + const state = getAuditState(); + if (!state) return []; + + const pubkey = state.pubkey; + const procedures = []; + + const k10002Changes = getStagedChanges(10002); + if (k10002Changes.length > 0) { + const ev = buildK10002Event(pubkey); + if (ev) { + procedures.push({ + name: 'Update NIP-65 Relay List', + kind: 10002, + description: `Publishing updated relay list with ${ev.tags.length} relay(s)`, + unsignedEvent: ev, + }); + } + } + + const k10050Changes = getStagedChanges(10050); + if (k10050Changes.length > 0) { + const ev = buildK10050Event(pubkey); + if (ev) { + procedures.push({ + name: 'Update Inbox Relays', + kind: 10050, + description: `Publishing updated inbox relays with ${ev.tags.length} relay(s)`, + unsignedEvent: ev, + }); + } + } + + const k10051Changes = getStagedChanges(10051); + if (k10051Changes.length > 0) { + const ev = buildK10051Event(pubkey); + if (ev) { + procedures.push({ + name: 'Update KeyPackage Relays', + kind: 10051, + description: `Publishing updated KeyPackage relay list with ${ev.tags.length} relay(s)`, + unsignedEvent: ev, + }); + } + } + + const k3Changes = getStagedChanges(3); + if (k3Changes.length > 0) { + const ev = buildK3Event(pubkey); + if (ev) { + procedures.push({ + name: 'Update Contact Relay Hints', + kind: 3, + description: `Publishing updated contacts with ${ev.tags.filter(t => t[0] === 'relay').length} relay hint(s)`, + unsignedEvent: ev, + }); + } + } + + return procedures; +} + +/** + * Execute a single procedure: sign via NIP-07 and publish to relays. + * @param {Procedure} procedure + * @param {string[]} publishRelays - Relay URLs to publish to + * @param {function} [onRelayStatus] - (relay: string, status: string) => void + * @returns {Promise} + */ +export async function executeProcedure(procedure, publishRelays, onRelayStatus) { + const result = { + name: procedure.name, + ok: false, + succeeded: 0, + failed: 0, + relayResults: [], + }; + + // Step 1: Sign via NIP-07 + if (!window.nostr) { + return { ...result, ok: false }; + } + + let signedEvent; + try { + signedEvent = await window.nostr.signEvent(procedure.unsignedEvent); + } catch (e) { + console.error('NIP-07 signing failed for', procedure.name, e); + return { ...result, ok: false }; + } + + // Step 2: Publish to relays + const pool = new SimplePool(); + const publishWithTimeout = (relay, ev) => + Promise.race([ + pool.publish([relay], ev), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), PUBLISH_TIMEOUT_MS), + ), + ]); + + for (const relay of publishRelays) { + onRelayStatus?.(relay, 'SENDING'); + let relayOk = true; + try { + await publishWithTimeout(relay, signedEvent); + } catch { + relayOk = false; + } + onRelayStatus?.(relay, relayOk ? 'OK' : 'FAIL'); + result.relayResults.push({ relay, ok: relayOk }); + if (relayOk) result.succeeded++; + else result.failed++; + } + + pool.close(publishRelays); + result.ok = result.succeeded > 0; + return result; +} + +/** + * Execute all planned procedures sequentially. + * @param {string[]} publishRelays + * @param {function} [onProcedureStart] - (procedure: Procedure, index: number, total: number) => void + * @param {function} [onProcedureDone] - (result: ProcedureResult, index: number, total: number) => void + * @param {function} [onRelayStatus] - (relay: string, status: string) => void + * @returns {Promise} + */ +export async function executeAllProcedures(publishRelays, onProcedureStart, onProcedureDone, onRelayStatus) { + const procedures = planProcedures(); + const results = []; + + for (let i = 0; i < procedures.length; i++) { + onProcedureStart?.(procedures[i], i, procedures.length); + const result = await executeProcedure(procedures[i], publishRelays, onRelayStatus); + results.push(result); + onProcedureDone?.(result, i, procedures.length); + } + + return results; +} diff --git a/js/operate/room.js b/js/operate/room.js new file mode 100644 index 0000000..e6eea27 --- /dev/null +++ b/js/operate/room.js @@ -0,0 +1,592 @@ +/** + * Operating Room β€” the main orchestrator. + * Transforms the relay panel into a surgical relay management view. + * Handles all user interaction, health diagnostics, and procedure execution. + */ + +import { getAuditState } from '../audit.js'; +import { relayList } from '../dom.js'; +import { say } from '../dialog.js'; +import { speak } from '../personalities.js'; +import { setSprite } from '../sprite.js'; +import { okBeep, errBeep, scanBeep } from '../audio.js'; +import { flashScreen, setRelayState, appendResultSection } from '../relay-panel.js'; +import { addScanBar, removeScanBar, setScanProgress } from '../scan-bar.js'; + +import { + initEditorState, + renderEditorPanel, + renderStagedSummary, + stageAddRelay, + stageRemoveRelay, + stageToggleReadWrite, + hasAnyStagedChanges, + totalStagedChanges, + clearAllChanges, + getRelayList, +} from './editor.js'; + +import { checkAllRelaysHealth } from './health.js'; +import { planProcedures, executeAllProcedures } from './procedures.js'; +import { autoStageFromPrescriptions } from './autostage.js'; +import { + initGuide, + isGuidedMode, + getCurrentStep, + getTotalSteps, + nextStep, + skipStep, + exitGuide, + switchToManual, + resumeGuide, + renderGuideCard, + renderGuideResumeBar, +} from './guide.js'; + +let isOperating = false; +let healthResults = null; +/** @type {HTMLElement[]} Saved chart DOM nodes (preserved with event listeners) */ +let savedChartNodes = []; + +/** + * Check if the Operating Room is currently active. + * @returns {boolean} + */ +export function isInOperatingRoom() { + return isOperating; +} + +/** + * Enter the Operating Room. + * Called when user clicks OPERATE on the patient chart. + */ +export async function enterOperatingRoom() { + const state = getAuditState(); + if (!state) { + await say(speak('noAuditData') || 'No audit data available.'); + return; + } + + if (!window.nostr) { + await say(speak('noNip07ForAction') || 'Operating Room requires NIP-07 for signing.'); + return; + } + + if (!state.fromNip07) { + await say(speak('orRequiresNip07') || 'Sign in with NIP-07 first β€” the Operating Room can only modify your own relays.'); + return; + } + + isOperating = true; + healthResults = new Map(); + + // Save current relay list DOM nodes (preserves event listeners) [S1 fix] + savedChartNodes = []; + while (relayList.firstChild) { + savedChartNodes.push(relayList.removeChild(relayList.firstChild)); + } + + // Initialize editor state from audit results + initEditorState(state); + + // Bind the delegated click handler once [C1 fix] + relayList.addEventListener('click', handleEditorClick); + + // Pre-OR briefing β€” doctor explains the surgical plan + setSprite('working', 'bounce'); + await say(speak('orIntro')); + + // Auto-stage from prescriptions + const compiled = state.compiledResult; + let autoStageResult = null; + if (compiled && compiled.prescriptions?.length > 0) { + autoStageResult = autoStageFromPrescriptions(state, compiled); + + // Pre-OR briefing with auto-stage summary + if (autoStageResult.staged > 0) { + await say(speak('orBriefingAutoStaged', { + staged: autoStageResult.staged, + actions: autoStageResult.actions.length, + })); + } + if (autoStageResult.suggestions.length > 0) { + await say(speak('orBriefingSuggestions', { + count: autoStageResult.suggestions.length, + })); + } + + // Initialize guided mode + initGuide(compiled.prescriptions, compiled.findings); + if (getTotalSteps() > 0) { + await say(speak('orBriefingGuided', { steps: getTotalSteps() })); + } + } + + // Render the editor (with guide card if in guided mode) + renderRoom(); + + // Start health checks in background + runHealthChecks(state); +} + +/** + * Exit the Operating Room, restoring the previous relay panel. + */ +export function exitOperatingRoom() { + isOperating = false; + healthResults = null; + clearAllChanges(); + exitGuide(); + + // Remove the delegated click handler [S2 fix] + relayList.removeEventListener('click', handleEditorClick); + + // Restore saved chart DOM nodes (with intact event listeners) [S1 fix] + relayList.innerHTML = ''; + for (const node of savedChartNodes) { + relayList.appendChild(node); + } + savedChartNodes = []; + + setSprite('idle'); +} + +/** + * Render or re-render the Operating Room UI. + * Does NOT re-bind the click handler β€” it's bound once on entry. [C1 fix] + */ +function renderRoom() { + let guideHtml = ''; + if (isGuidedMode()) { + const step = getCurrentStep(); + if (step) { + guideHtml = renderGuideCard(step); + } + } else if (getTotalSteps() > 0) { + guideHtml = renderGuideResumeBar(); + } + + const editorHtml = renderEditorPanel(healthResults); + relayList.innerHTML = guideHtml + editorHtml; + relayList.scrollTop = 0; + bindInputEvents(); + + // Highlight targeted tray in guided mode + if (isGuidedMode()) { + const step = getCurrentStep(); + if (step?.targetKind) { + const tray = relayList.querySelector(`.or-tray[data-kind="${step.targetKind}"]`); + if (tray) { + tray.classList.add('or-tray-highlighted'); + tray.classList.remove('or-tray-collapsed'); + } + } + } +} + +/** + * Run health checks on all relays across all kinds. + * @param {Object} auditState - Audit state + */ +async function runHealthChecks(auditState) { + // Collect all unique relay URLs from all kinds + const allUrls = new Set(); + for (const kind of [10002, 10050, 10051, 3]) { + for (const entry of getRelayList(kind)) { + allUrls.add(entry.url); + } + } + // Also include the investigation relays + if (auditState.relaysToInvestigate) { + for (const url of auditState.relaysToInvestigate) allUrls.add(url); + } + + if (allUrls.size === 0) return; + + scanBeep(); + await say(speak('orHealthStart') || 'Running relay diagnostics...'); + addScanBar(); + setScanProgress(10); + + let checked = 0; + const total = allUrls.size; + + healthResults = await checkAllRelaysHealth( + [...allUrls], + (_url, phase) => { + // onRelayProgress β€” update status in real-time if visible + if (phase === 'connecting') scanBeep(); + }, + (_result) => { + // onRelayDone + checked++; + const pct = Math.round(10 + (80 * checked / total)); + setScanProgress(pct); + }, + ); + + setScanProgress(100); + removeScanBar(); + + // Report summary + const dead = [...healthResults.values()].filter(r => r.status === 'dead').length; + const slow = [...healthResults.values()].filter(r => r.status === 'warn').length; + const healthy = [...healthResults.values()].filter(r => r.status === 'ok').length; + + if (dead > 0) { + await say(speak('orHealthDead', { count: dead }) || `${dead} relay(s) unreachable. Consider removing dead relays.`); + } else if (slow > 0) { + await say(speak('orHealthSlow', { count: slow }) || `${slow} relay(s) running slow. ${healthy} healthy.`); + } else { + await say(speak('orHealthGood', { count: healthy }) || `All ${healthy} relay(s) responding. Vitals stable.`); + } + + // Re-render with health data + renderRoom(); +} + +/** + * Bind keydown events on add-relay inputs. + * These are on elements replaced by innerHTML, so must be rebound on each render. + * The click handler is NOT rebound β€” it's delegated on relayList once. + */ +function bindInputEvents() { + const inputs = relayList.querySelectorAll('.or-add-input'); + for (const input of inputs) { + input.addEventListener('keydown', handleAddInputKeydown); + } +} + +/** + * Compute new read/write state when toggling a bit. + * @param {string} currentReadWrite - Current state: 'read'|'write'|'rw' + * @param {'read'|'write'} toggleBit - Which bit to flip + * @returns {string} New read/write state + */ +function computeNewReadWrite(currentReadWrite, toggleBit) { + const isRead = currentReadWrite === 'read' || currentReadWrite === 'rw'; + const isWrite = currentReadWrite === 'write' || currentReadWrite === 'rw'; + + let newRead = isRead; + let newWrite = isWrite; + + if (toggleBit === 'read') newRead = !isRead; + else newWrite = !isWrite; + + // Can't have neither β€” default to both + if (!newRead && !newWrite) return 'rw'; + if (newRead && newWrite) return 'rw'; + if (newRead) return 'read'; + return 'write'; +} + +/** + * Handle delegated click events in the editor. + * @param {Event} e + */ +async function handleEditorClick(e) { + // Only handle OR actions (prefixed with or- data-actions or known OR actions) + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const action = btn.dataset.action; + + // Guard: only handle OR-specific actions to avoid collisions + const OR_ACTIONS = [ + 'toggle-tray', 'add-relay', 'remove-relay', 'undo-remove', + 'toggle-rw', 'discard-all', 'commit-operate', 'close-or', + 'guide-next', 'guide-skip', 'guide-manual', 'guide-resume', 'guide-fix', + ]; + if (!OR_ACTIONS.includes(action)) return; + + const kind = btn.dataset.kind ? Number(btn.dataset.kind) : null; + const url = btn.dataset.url; + + switch (action) { + case 'toggle-tray': { + const tray = btn.closest('.or-tray'); + if (tray) tray.classList.toggle('or-tray-collapsed'); + break; + } + case 'add-relay': { + if (kind === null) break; + const input = relayList.querySelector(`.or-add-input[data-kind="${kind}"]`); + const errorEl = relayList.querySelector(`.or-add-error[data-kind="${kind}"]`); + if (!input) break; + const relayUrl = input.value.trim(); + if (!relayUrl) break; + + const result = stageAddRelay(kind, relayUrl); + if (result.ok) { + input.value = ''; + errorEl?.classList.add('hidden'); + scanBeep(); + renderRoom(); + } else { + if (errorEl) { + errorEl.textContent = result.reason || 'Invalid relay URL'; + errorEl.classList.remove('hidden'); + } + } + break; + } + case 'remove-relay': { + if (kind === null || !url) break; + const result = stageRemoveRelay(kind, url); + if (result.ok) { + scanBeep(); + renderRoom(); + } + break; + } + case 'undo-remove': { + if (kind === null || !url) break; + // Re-add is equivalent to staging an add for a removed relay + stageAddRelay(kind, url); + scanBeep(); + renderRoom(); + break; + } + case 'toggle-rw': { + if (!url) break; + const entry = getRelayList(10002).find( + r => r.url.toLowerCase() === url.toLowerCase() && !r.removed, + ); + if (!entry) break; + + const newRw = computeNewReadWrite(entry.readWrite, btn.dataset.rw); + stageToggleReadWrite(url, newRw); + scanBeep(); + renderRoom(); + break; + } + case 'discard-all': { + clearAllChanges(); + await say(speak('orDiscard') || 'Changes discarded.'); + renderRoom(); + break; + } + case 'commit-operate': { + await executeOperations(); + break; + } + case 'close-or': { + exitOperatingRoom(); + await say(speak('orClose') || 'Operating Room closed.'); + break; + } + case 'guide-next': { + const next = nextStep(); + if (next) { + await say(speak('guideStepAdvance', { step: next.index + 1, total: getTotalSteps() })); + } else { + exitGuide(); + await say(speak('guideComplete') || 'Treatment plan complete. Review and operate when ready.'); + } + renderRoom(); + break; + } + case 'guide-skip': { + const next = skipStep(); + if (next) { + await say(speak('guideStepSkip') || 'Skipped. Moving on.'); + } else { + exitGuide(); + await say(speak('guideComplete') || 'Treatment plan complete.'); + } + renderRoom(); + break; + } + case 'guide-manual': { + switchToManual(); + await say(speak('guideManualMode') || 'Manual mode. Edit freely.'); + renderRoom(); + break; + } + case 'guide-resume': { + resumeGuide(); + await say(speak('guideResume') || 'Resuming guided treatment.'); + renderRoom(); + break; + } + case 'guide-fix': { + const fixAction = btn.dataset.fixAction; + if (!fixAction) break; + btn.disabled = true; + btn.textContent = 'WORKING…'; + let succeeded = false; + try { + await executeFixAction(fixAction); + succeeded = true; + } catch (err) { + console.error('Guide fix action failed', err); + errBeep(); + } + btn.disabled = false; + btn.textContent = succeeded ? 'DONE βœ”' : 'ERROR'; + if (succeeded) { + okBeep(); + // Auto-advance to next step after fix + const next = nextStep(); + if (next) { + await say(speak('guideStepAdvance', { step: next.index + 1, total: getTotalSteps() })); + } else { + exitGuide(); + await say(speak('guideComplete') || 'Treatment plan complete.'); + } + renderRoom(); + } + break; + } + } +} + +/** + * Execute a fix action by name (dispatches to the appropriate actions.js function). + * @param {string} fixAction - One of: rebroadcast, delete-kps, delete-orphaned-kps, unify-relays, delete-kind4 + */ +async function executeFixAction(fixAction) { + switch (fixAction) { + case 'rebroadcast': { + const { rebroadcastProfileAndContacts } = await import('../actions.js'); + await rebroadcastProfileAndContacts(); + break; + } + case 'delete-kps': { + const { deleteKeyPackages } = await import('../actions.js'); + await deleteKeyPackages(); + break; + } + case 'delete-orphaned-kps': { + const { deleteOrphanedKeyPackages } = await import('../actions.js'); + await deleteOrphanedKeyPackages(); + break; + } + case 'unify-relays': { + const { unifyRelayLists } = await import('../actions.js'); + await unifyRelayLists(); + break; + } + case 'delete-kind4': { + const { deleteDeprecatedKind4 } = await import('../actions.js'); + await deleteDeprecatedKind4(); + break; + } + default: + console.error('Unknown fix action:', fixAction); + } +} + +/** + * Handle Enter key on add-relay inputs. + * @param {KeyboardEvent} e + */ +function handleAddInputKeydown(e) { + if (e.key !== 'Enter') return; + const kind = Number(e.target.dataset.kind); + const addBtn = relayList.querySelector(`.or-add-btn[data-kind="${kind}"]`); + if (addBtn) addBtn.click(); +} + +/** + * Execute all staged operations (the surgery itself). + */ +async function executeOperations() { + if (!hasAnyStagedChanges()) { + await say('No changes to apply.'); + return; + } + + const auditState = getAuditState(); + if (!auditState) return; + + const procedures = planProcedures(); + if (procedures.length === 0) { + await say('No procedures to execute.'); + return; + } + + // Show diff preview + const diffHtml = renderStagedSummary(); + if (diffHtml) { + const diffContainer = document.createElement('div'); + diffContainer.innerHTML = diffHtml; + relayList.appendChild(diffContainer); + relayList.scrollTop = relayList.scrollHeight; + } + + const stagedCount = totalStagedChanges(); + setSprite('working', 'bounce'); + await say(speak('orOperateStart', { count: procedures.length, changes: stagedCount })); + + addScanBar(); + setScanProgress(5); + + // Determine publish targets: all relays we know about + const publishRelays = [...new Set([ + ...auditState.relaysToInvestigate, + ...auditState.marmotRelays, + ])]; + + const results = await executeAllProcedures( + publishRelays, + async (procedure, index, total) => { + const pct = Math.round(5 + (85 * index / total)); + setScanProgress(pct); + await say(speak('orProcedure', { index: index + 1, total, name: procedure.name })); + }, + async (result, index, total) => { + const pct = Math.round(5 + (85 * (index + 1) / total)); + setScanProgress(pct); + if (result.ok) { + await say(speak('orProcedureOk', { name: result.name, succeeded: result.succeeded })); + } else { + errBeep(); + await say(speak('orProcedureFail', { name: result.name, failed: result.failed })); + } + }, + (relay, status) => { + // [C2 fix] Renamed to avoid shadowing outer `auditState` + const cssState = status === 'SENDING' ? 'connecting' : status === 'OK' ? 'ok' : 'error'; + setRelayState(relay, cssState, status); + }, + ); + + setScanProgress(100); + removeScanBar(); + + // Compile results + const allSucceeded = results.every(r => r.ok); + const totalSucceeded = results.reduce((sum, r) => sum + r.succeeded, 0); + const totalFailed = results.reduce((sum, r) => sum + r.failed, 0); + + const resultItems = results.map(r => ({ + type: r.ok ? 'ok' : 'err', + text: `${r.name}: ${r.succeeded} relay(s) updated${r.failed > 0 ? `, ${r.failed} failed` : ''}`, + })); + appendResultSection('SURGICAL RESULTS', resultItems); + + if (allSucceeded) { + setSprite('idle', 'success'); + flashScreen('ok'); + okBeep(); + setTimeout(() => okBeep(), 200); + await say(speak('orSuccess', { procedures: procedures.length, relays: totalSucceeded })); + } else if (totalSucceeded > 0) { + setSprite('writing', 'bounce'); + flashScreen('ok'); + okBeep(); + await say(speak('orPartial', { succeeded: totalSucceeded, failed: totalFailed })); + } else { + setSprite('blocked', 'error'); + flashScreen('err'); + errBeep(); + await say(speak('orFail')); + } + + // Clear staged changes after execution + clearAllChanges(); + + // Re-render to show clean state + renderRoom(); +} diff --git a/js/personalities.js b/js/personalities.js index 89d1a22..27e1b98 100644 --- a/js/personalities.js +++ b/js/personalities.js @@ -86,6 +86,42 @@ const LINES = { unifyRelaysStart: "Merging your k3 and k10002 relay lists into a single unified set and broadcasting to all relays...", unifyRelaysDone: "Relay lists unified. {count} relay(s) now carry a consistent k3 and k10002 " + "with {total} relay(s) each.", + // ── Operating Room ── + orIntro: "Prep the operating room. We have work to do. *snaps on gloves*", + orRequiresNip07: "I cannot operate on a patient who hasn't signed in. Use NIP-07 first β€” I need your consent.", + orHealthStart: "Running relay diagnostics β€” checking pulse on every connection...", + orHealthGood: "All {count} relay(s) responding. Vitals stable. Ready for surgery.", + orHealthSlow: "{count} relay(s) running slow. We can still operate, but watch for complications.", + orHealthDead: "{count} relay(s) unreachable. Dead connections. Consider removing them during surgery.", + orDiscard: "Changes discarded. The operating table is clean.", + orClose: "Operating Room sealed. Returning to patient chart.", + orOperateStart: "Scalpel. I've prepared {count} procedure(s) from {changes} staged change(s). Requesting NIP-07 signatures β€” approve each one.", + orProcedure: "Procedure {index} of {total}: {name}...", + orProcedureOk: "{name} β€” published to {succeeded} relay(s). Vitals stable.", + orProcedureFail: "{name} β€” {failed} relay(s) rejected. Complications detected.", + orSuccess: "Surgery complete. {procedures} procedure(s) successful across {relays} relay(s). The patient will make a full recovery. *removes gloves*", + orPartial: "Surgery partially successful β€” {succeeded} relays updated, {failed} failed. Some sutures didn't hold.", + orFail: "Surgery failed. No relays accepted the changes. Check your NIP-07 extension and relay connectivity.", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "Based on my diagnosis, I've pre-staged {staged} fix(es) for you. Review them in the trays below β€” modify or discard as you see fit.", + orBriefingSuggestions: "There are also {count} item(s) that need your judgment β€” things I can't auto-fix. I'll walk you through them.", + orBriefingGuided: "I've prepared a {steps}-step treatment plan. I'll guide you through each prescription. Follow my lead, or switch to manual mode if you prefer.", + // ── Guided Mode ── + guideStepNote: "Step {step} of {total}. Follow the prescription, then advance when ready.", + guideStepAdvance: "Good. Moving to step {step} of {total}. Next prescription ready.", + guideStepSkip: "Skipped. We'll come back to it if needed. *makes note on clipboard*", + guideComplete: "Treatment plan reviewed. All prescriptions addressed. Stage any remaining changes, then operate when ready. *sets clipboard down*", + guideManualMode: "Manual mode engaged. The operating table is yours β€” edit freely. I'll be here if you need to resume the guided flow.", + guideResume: "Resuming guided treatment. *picks up clipboard* Where were we...", + // ── Doctor's Notes ── + doctorNoteClean: "Patient presents with excellent relay hygiene. {passCount} checks passed across {relayCount} relay(s). Profile, contacts, and Marmot protocol all in order. No intervention required β€” a textbook case. *nods approvingly*", + doctorNoteMinor: "Overall health is stable. {warnCount} minor observation(s) noted β€” nothing that threatens connectivity, but addressing them would strengthen the patient's relay posture. {passCount} checks passed. Prognosis: good with minor maintenance.", + doctorNoteCritical: "Critical issues detected. {failCount} failure(s) and {warnCount} warning(s) require attention. Multiple systems affected β€” this patient needs the operating room. Follow the treatment plan in priority order.", + doctorNoteRelayConfig: "Relay configuration is the root issue. Invalid URLs are breaking relay discovery β€” clients can't find the patient. Fix the URLs first; everything downstream depends on valid relay addresses.", + doctorNoteSync: "The patient's data is fragmented across relays. Profile and contacts are out of sync β€” some relays carry stale versions. A rebroadcast will restore consistency. This is the primary intervention needed.", + doctorNoteMarmot: "Marmot Protocol foundation is incomplete. The patient is missing critical relay advertisements β€” without kind 10050 or 10051, encrypted messaging is impossible. Publish the missing events to enable Marmot.", + doctorNoteKeyPackage: "KeyPackages have MIP-00/01 compliance failures. Encoding, extensions, or ciphersuite tags don't meet spec. Your Marmot client needs to publish corrected KeyPackages β€” this is a client-side fix.", + doctorNoteMultiple: "Multiple systems need attention β€” {categoryCount} categories of issues detected. Start with relay and sync fixes so that KeyPackages propagate correctly, then address Marmot setup. Prioritize the treatment plan top to bottom.", }, sunny: { intro: "Hi!! I'm Sunny! 🌞 Paste an npub and I'll check everything β€” relays, sync, Marmot stuff, the works! Let's go!!", @@ -142,9 +178,45 @@ const LINES = { unifyRelaysStart: "Merging your k3 and k10002 relay lists into one and broadcasting to all relays!!", unifyRelaysDone: "All unified!! {count} relay(s) updated with a consistent " + "list of {total} relay(s)! ✨", + // ── Operating Room ── + orIntro: "Ooh!! We're doing surgery!! *puts on tiny gloves* Let's fix those relays!!", + orRequiresNip07: "We need NIP-07 first! Sign in so I can help you!", + orHealthStart: "Checking all your relays! *bounces excitedly* Diagnostics go!!", + orHealthGood: "All {count} relay(s) healthy!! Yay! Ready for surgery!", + orHealthSlow: "{count} relay(s) are being sluggish! We'll work with it!", + orHealthDead: "{count} relay(s) are dead!! Oh no! Maybe remove them?", + orDiscard: "All gone! Clean slate! ✨", + orClose: "Closing up! Back to the chart!", + orOperateStart: "Here we go!! {count} procedure(s)! Approve the NIP-07 signatures!!", + orProcedure: "Procedure {index} of {total}: {name}!! So exciting!!", + orProcedureOk: "{name} done!! {succeeded} relay(s) updated!! ✨", + orProcedureFail: "{name} β€” {failed} relay(s) said no!! Oh no!!", + orSuccess: "Surgery complete!! {procedures} procedure(s) done across {relays} relay(s)!! Patient is all better!! 🌟", + orPartial: "Almost! {succeeded} worked, {failed} didn't! Still progress!", + orFail: "Surgery didn't work!! The relays wouldn't cooperate! Try again?", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "Ooh! I already figured out {staged} fix(es) from your diagnosis! Check them out below!! ✨", + orBriefingSuggestions: "Also! {count} thing(s) need your input β€” I couldn't auto-fix those! Let's look together!", + orBriefingGuided: "I made a {steps}-step treatment plan!! Follow along or go free-form! Let's fix everything!! 🌟", + // ── Guided Mode ── + guideStepNote: "Step {step} of {total}! Let's tackle this one!", + guideStepAdvance: "Yay! Step {step} of {total} next!!", + guideStepSkip: "Skipping! We'll circle back! Moving on!", + guideComplete: "All steps done!! Review everything and hit OPERATE when you're ready!! ✨", + guideManualMode: "Manual mode! Edit whatever you want! Come back to guided anytime!", + guideResume: "Back to guided mode! Let's keep going!! 🌟", + // ── Doctor's Notes ── + doctorNoteClean: "Everything looks amazing!! {passCount} checks passed on {relayCount} relay(s)! Profile, contacts, Marmot β€” all perfect! No fixes needed!! You're a star patient!! ✨", + doctorNoteMinor: "Looking pretty good! {warnCount} little thing(s) to watch β€” nothing scary! {passCount} checks passed! Just some tidying up and you'll be perfect!", + doctorNoteCritical: "Oh no! {failCount} critical issue(s) and {warnCount} warning(s)! We've got work to do! Follow the treatment plan β€” we'll get through this together!!", + doctorNoteRelayConfig: "The relay URLs are the problem! Invalid addresses are confusing clients! Fix those first and everything else should fall into place!", + doctorNoteSync: "Your data is scattered! Some relays have old stuff! A rebroadcast will sync everything up β€” that's the main fix!", + doctorNoteMarmot: "Marmot messaging isn't set up yet! Missing some key pieces (k10050 / k10051). Let's publish those and get you connected!", + doctorNoteKeyPackage: "KeyPackages need some TLC! Encoding or tag issues from MIP-00/01. Your Marmot client needs to fix and republish them!", + doctorNoteMultiple: "Multiple things need fixing β€” {categoryCount} different areas! Start with relay/sync stuff, then Marmot! Follow the plan top to bottom!", }, bubbly: { - /* inherits from sunny */ + /* inherits from sunny β€” including Operating Room lines */ }, professor: { intro: "Good day. I am Professor Grey. Present an npub and I shall conduct a comprehensive diagnostic: relay discovery via k3/k10002/k10051, synchronization verification, Marmot Protocol compliance, and vital sign assessment. Proceed.", @@ -203,6 +275,42 @@ const LINES = { + "registered relays.", unifyRelaysDone: "Unification complete. {count} relay(s) updated. k3 and " + "k10002 now share a consistent list of {total} relay(s).", + // ── Operating Room ── + orIntro: "Preparing the operating theatre. Relay management requires precision. Proceed when ready.", + orRequiresNip07: "NIP-07 authentication is prerequisite for surgical procedures. Please authenticate first.", + orHealthStart: "Initiating relay diagnostic battery. Measuring connectivity, latency, and protocol compliance.", + orHealthGood: "All {count} relay(s) satisfactory. Conditions nominal for surgery.", + orHealthSlow: "{count} relay(s) exhibiting elevated latency. Operable, but monitor closely.", + orHealthDead: "{count} relay(s) unresponsive. Recommend excision during this procedure.", + orDiscard: "Staged modifications discarded. Operating table sterilized.", + orClose: "Operating theatre sealed. Returning to diagnostic summary.", + orOperateStart: "Commencing {count} surgical procedure(s) derived from {changes} modification(s). NIP-07 authorization required for each.", + orProcedure: "Procedure {index} of {total}: {name}. Executing.", + orProcedureOk: "{name} β€” successfully propagated to {succeeded} relay(s).", + orProcedureFail: "{name} β€” {failed} relay(s) declined. Surgical complication noted.", + orSuccess: "All procedures concluded successfully. {procedures} operation(s), {relays} relay(s) updated. Prognosis: excellent.", + orPartial: "Partial success. {succeeded} relay(s) updated; {failed} unsuccessful. Follow-up recommended.", + orFail: "Procedures unsuccessful. No relays accepted modifications. Investigate NIP-07 and relay availability.", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "Based on the diagnostic findings, I have pre-staged {staged} corrective measure(s). Review each in the trays below. Modify or discard as your judgment dictates.", + orBriefingSuggestions: "Additionally, {count} item(s) require manual assessment β€” these cannot be automated. I shall guide you through each.", + orBriefingGuided: "I have prepared a {steps}-step treatment protocol. Each prescription will be presented in sequence. You may proceed in order or switch to manual editing at any point.", + // ── Guided Mode ── + guideStepNote: "Step {step} of {total}. Address this prescription, then advance.", + guideStepAdvance: "Proceeding to step {step} of {total}. Next prescription queued.", + guideStepSkip: "Noted β€” prescription deferred. Advancing to next item.", + guideComplete: "Treatment protocol reviewed in its entirety. Verify staged modifications, then execute when satisfied.", + guideManualMode: "Manual mode engaged. You have full editorial control. The guided protocol may be resumed at any time.", + guideResume: "Resuming the treatment protocol from the current position.", + // ── Doctor's Notes ── + doctorNoteClean: "The patient demonstrates exemplary relay hygiene. {passCount} diagnostic checks passed across {relayCount} relay(s). Profile metadata, contact lists, and Marmot Protocol compliance are all satisfactory. No intervention is indicated.", + doctorNoteMinor: "The patient's overall condition is satisfactory. {warnCount} minor observation(s) have been documented β€” none represent critical failures. {passCount} checks passed. Recommended: address warnings at the patient's convenience.", + doctorNoteCritical: "The diagnostic reveals {failCount} critical failure(s) and {warnCount} advisory warning(s). Multiple subsystems are affected. Surgical intervention via the Operating Room is strongly recommended. Adhere to the treatment plan in prescribed order.", + doctorNoteRelayConfig: "The primary pathology is relay configuration. Malformed URLs impede relay discovery and event propagation. Correct the invalid addresses first; subsequent diagnostic outcomes depend upon valid relay infrastructure.", + doctorNoteSync: "Data synchronization across relays is the principal concern. Profile and contact events exhibit version discrepancies. A comprehensive rebroadcast operation will restore consistency across the relay network.", + doctorNoteMarmot: "The Marmot Protocol foundation is structurally incomplete. Absence of kind 10050 or 10051 events renders encrypted messaging inoperable. Publication of the missing relay advertisement events is the required intervention.", + doctorNoteKeyPackage: "KeyPackage events present MIP-00/01 compliance deficiencies. Encoding specifications, extension declarations, or ciphersuite designations do not conform to protocol requirements. The originating Marmot client must issue corrected KeyPackages.", + doctorNoteMultiple: "The diagnostic identifies issues across {categoryCount} distinct categories. The recommended approach: resolve relay configuration and synchronization anomalies first to establish reliable propagation, then address Marmot Protocol deficiencies. Prioritize the treatment plan sequentially.", }, sparky: { intro: "Hey! Sparky here! πŸ”₯ Give me an npub and I'll run the full diagnostic β€” relays, sync, Marmot, vitals, the whole deal!", @@ -257,6 +365,42 @@ const LINES = { unifyRelaysStart: "Merging k3 and k10002 relay lists and broadcasting!", unifyRelaysDone: "Unified! {count} relay(s) updated. k3 and k10002 now agree " + "on {total} relay(s)!", + // ── Operating Room ── + orIntro: "OR is prepped! Let's get those relays sorted! Gloves on!", + orRequiresNip07: "Need NIP-07 to operate! Sign in first!", + orHealthStart: "Running diagnostics on all relays! Pinging everything!", + orHealthGood: "All {count} relay(s) alive! Green across the board! Let's go!", + orHealthSlow: "{count} slow relay(s). Might lag but we'll push through!", + orHealthDead: "{count} dead relay(s)! Cut those loose!", + orDiscard: "Wiped clean! Nothing staged!", + orClose: "OR closed! Back to chart!", + orOperateStart: "Firing up {count} procedure(s)! {changes} changes queued! NIP-07 signatures incoming!", + orProcedure: "Procedure {index}/{total}: {name}!", + orProcedureOk: "{name} β€” {succeeded} relay(s) done!", + orProcedureFail: "{name} β€” {failed} relay(s) rejected!", + orSuccess: "All done! {procedures} procedure(s), {relays} relay(s) updated! Clean!", + orPartial: "{succeeded} ok, {failed} failed. Mostly there!", + orFail: "Nothing went through! Check relays and NIP-07!", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "Already pre-loaded {staged} fix(es) from the diagnosis! Check the trays β€” tweak as needed!", + orBriefingSuggestions: "Also got {count} thing(s) that need your call β€” couldn't auto-fix those!", + orBriefingGuided: "Got a {steps}-step plan ready! Follow along or go manual! Let's do this!", + // ── Guided Mode ── + guideStepNote: "Step {step}/{total}. This one's up!", + guideStepAdvance: "Next! Step {step}/{total}!", + guideStepSkip: "Skipped! Moving!", + guideComplete: "Plan done! Stage your changes and operate!", + guideManualMode: "Free mode! Edit away!", + guideResume: "Back to guided! Let's go!", + // ── Doctor's Notes ── + doctorNoteClean: "Clean! {passCount} checks passed on {relayCount} relay(s). Everything's solid β€” no fixes needed!", + doctorNoteMinor: "Mostly good! {warnCount} minor thing(s) β€” nothing urgent. {passCount} passed. Quick fixes when you get a chance!", + doctorNoteCritical: "{failCount} critical! {warnCount} warnings! Let's get to work β€” follow the plan!", + doctorNoteRelayConfig: "Relay URLs are busted β€” fix those first! Everything depends on valid addresses!", + doctorNoteSync: "Data's out of sync! Rebroadcast will fix it β€” that's the move!", + doctorNoteMarmot: "Marmot's not set up β€” missing k10050/k10051! Publish those!", + doctorNoteKeyPackage: "KeyPackages need fixing β€” MIP-00/01 compliance issues. Client needs to republish!", + doctorNoteMultiple: "{categoryCount} categories of issues! Start with relay/sync, then Marmot! Top to bottom!", }, nuts: { intro: "GREETINGS!! I am Dr. Nuts!! Enter an NPUB and witness my DIAGNOSTIC PROTOCOL!! Relay discovery! Sync verification! Marmot compliance! VITALS!! *cackles*", @@ -312,6 +456,42 @@ const LINES = { unifyRelaysStart: "MERGING RELAY LISTS!! k3 and k10002 SHALL BE UNIFIED!! BROADCASTING!!", unifyRelaysDone: "UNIFICATION COMPLETE!! {count} relay(s) SYNCHRONIZED!! " + "k3 and k10002 now share {total} relay(s)!!", + // ── Operating Room ── + orIntro: "THE OPERATING ROOM IS MINE!! PREPARE THE RELAYS!! *cackles maniacally*", + orRequiresNip07: "I CANNOT OPERATE WITHOUT NIP-07!! The SCIENCE DEMANDS AUTHENTICATION!!", + orHealthStart: "DIAGNOSTICS!! Pinging EVERY RELAY!! The measurements WILL BE PRECISE!!", + orHealthGood: "ALL {count} RELAY(S) ALIVE!! EXCELLENT!! The subjects are HEALTHY!!", + orHealthSlow: "{count} RELAY(S) SLUGGISH!! UNACCEPTABLE!! But operable!!", + orHealthDead: "{count} RELAY(S) DECEASED!! REMOVE THEM!! Dead relays have NO PLACE in my laboratory!!", + orDiscard: "DISCARDED!! The experiment RESETS!!", + orClose: "OPERATING ROOM SEALED!! Until next time!!", + orOperateStart: "COMMENCING {count} PROCEDURE(S)!! {changes} MODIFICATIONS!! SIGN THE NIP-07 REQUESTS!!", + orProcedure: "PROCEDURE {index} OF {total}: {name}!! THE EXPERIMENT CONTINUES!!", + orProcedureOk: "{name} β€” {succeeded} RELAY(S) ACCEPTED!! SUCCESS!!", + orProcedureFail: "{name} β€” {failed} RELAY(S) RESISTED!! THEY DARE DEFY ME!!", + orSuccess: "SURGERY COMPLETE!! {procedures} PROCEDURE(S)!! {relays} RELAY(S)!! THE EXPERIMENT IS A TRIUMPH!!", + orPartial: "{succeeded} SUCCEEDED!! {failed} RESISTED!! PARTIAL VICTORY IS STILL VICTORY!!", + orFail: "THE RELAYS HAVE REBELLED!! NONE ACCEPTED!! I WILL NOT BE DEFEATED!!", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "I HAVE ALREADY PRE-STAGED {staged} FIX(ES)!! My GENIUS has anticipated your needs!! Review them!! MODIFY IF YOU DARE!!", + orBriefingSuggestions: "ADDITIONALLY!! {count} ITEM(S) require YOUR judgment!! Even I cannot automate EVERYTHING!! *cackles*", + orBriefingGuided: "BEHOLD!! A {steps}-STEP TREATMENT PROTOCOL!! Follow my INSTRUCTIONS PRECISELY!! Or go rogue!! I DARE YOU!!", + // ── Guided Mode ── + guideStepNote: "STEP {step} OF {total}!! EXECUTE THE PRESCRIPTION!!", + guideStepAdvance: "ADVANCING!! STEP {step} OF {total}!! THE EXPERIMENT CONTINUES!!", + guideStepSkip: "SKIPPED!! WE SHALL RETURN TO IT!! NOTHING ESCAPES MY PROTOCOLS!!", + guideComplete: "ALL PRESCRIPTIONS REVIEWED!! THE TREATMENT PROTOCOL IS COMPLETE!! NOW OPERATE!!", + guideManualMode: "MANUAL MODE!! YOU THINK YOU CAN DO BETTER?! PROVE IT!!", + guideResume: "RESUMING GUIDED MODE!! BACK UNDER MY SUPERVISION!! WHERE YOU BELONG!!", + // ── Doctor's Notes ── + doctorNoteClean: "THE PATIENT IS PERFECT!! {passCount} CHECKS PASSED ACROSS {relayCount} RELAY(S)!! A FLAWLESS SPECIMEN!! NO INTERVENTION NEEDED!! *adjusts monocle*", + doctorNoteMinor: "ACCEPTABLE!! {warnCount} MINOR OBSERVATION(S)!! {passCount} PASSED!! Nothing CRITICAL but PERFECTION DEMANDS ATTENTION TO DETAIL!!", + doctorNoteCritical: "CATASTROPHIC!! {failCount} FAILURE(S)!! {warnCount} WARNING(S)!! MULTIPLE SYSTEMS COMPROMISED!! TO THE OPERATING ROOM!! IMMEDIATELY!!", + doctorNoteRelayConfig: "THE RELAY URLS ARE CORRUPTED!! Invalid addresses!! DISCOVERY IMPOSSIBLE!! FIX THEM FIRST!! THE EXPERIMENT CANNOT PROCEED WITHOUT VALID INFRASTRUCTURE!!", + doctorNoteSync: "DATA FRAGMENTATION DETECTED!! RELAYS CARRY CONFLICTING VERSIONS!! A REBROADCAST WILL RESTORE ORDER!! THIS IS THE PRIMARY INTERVENTION!!", + doctorNoteMarmot: "MARMOT PROTOCOL INCOMPLETE!! KIND 10050 AND 10051 ARE MISSING!! WITHOUT THEM, ENCRYPTED MESSAGING IS IMPOSSIBLE!! PUBLISH THEM!! NOW!!", + doctorNoteKeyPackage: "KEYPACKAGES FAIL MIP-00/01!! ENCODING!! EXTENSIONS!! CIPHERSUITES!! ALL NON-COMPLIANT!! The originating client MUST republish!! THIS IS BEYOND MY OPERATING TABLE!!", + doctorNoteMultiple: "{categoryCount} CATEGORIES OF FAILURE!! START WITH RELAY AND SYNC!! THEN MARMOT!! FOLLOW THE TREATMENT PLAN!! TOP!! TO!! BOTTOM!!", }, daimon: { intro: "I am Daimon. Enter an npub. I will run the diagnostic. I never fail. *adjusts scrub cap*", @@ -366,6 +546,42 @@ const LINES = { unifyRelaysStart: "Merging k3 and k10002 relay lists. Broadcasting.", unifyRelaysDone: "Done. {count} relay(s) updated. k3 and k10002 now carry " + "{total} relay(s).", + // ── Operating Room ── + orIntro: "Operating Room. I will fix what is broken. *adjusts scrub cap*", + orRequiresNip07: "NIP-07 required. Cannot operate without authentication.", + orHealthStart: "Diagnostics. Stand by.", + orHealthGood: "{count} relay(s) online. Acceptable.", + orHealthSlow: "{count} slow. Operable.", + orHealthDead: "{count} dead. Remove them.", + orDiscard: "Discarded.", + orClose: "Done.", + orOperateStart: "{count} procedure(s). {changes} changes. Sign.", + orProcedure: "Procedure {index}/{total}: {name}.", + orProcedureOk: "{name} β€” {succeeded} relay(s).", + orProcedureFail: "{name} β€” {failed} rejected.", + orSuccess: "Complete. {procedures} procedure(s). {relays} relay(s). Clean.", + orPartial: "{succeeded} ok. {failed} failed.", + orFail: "Failed. No relays accepted.", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "Pre-staged {staged} fix(es). Review. Adjust if needed.", + orBriefingSuggestions: "{count} item(s) need your decision.", + orBriefingGuided: "{steps} steps in the treatment plan. Follow or go manual.", + // ── Guided Mode ── + guideStepNote: "Step {step}/{total}.", + guideStepAdvance: "Step {step}/{total}. Next.", + guideStepSkip: "Skipped.", + guideComplete: "Plan complete. Operate when ready.", + guideManualMode: "Manual mode.", + guideResume: "Resuming guided mode.", + // ── Doctor's Notes ── + doctorNoteClean: "{passCount} checks passed. {relayCount} relay(s). Clean. No intervention.", + doctorNoteMinor: "{warnCount} minor issue(s). {passCount} passed. Address when convenient.", + doctorNoteCritical: "{failCount} critical. {warnCount} warnings. Operating Room recommended.", + doctorNoteRelayConfig: "Relay URLs are invalid. Fix those first. Everything depends on them.", + doctorNoteSync: "Out of sync. Rebroadcast. That's the fix.", + doctorNoteMarmot: "Marmot incomplete. Publish k10050 and k10051.", + doctorNoteKeyPackage: "KeyPackages non-compliant. Client needs to republish.", + doctorNoteMultiple: "{categoryCount} categories. Fix relay/sync first, then Marmot.", }, jeff: { intro: "Enter an npub to run a diagnostic: relay discovery, sync verification, MIP-00/01 compliance, and profile vitals. Or use NIP-07 to sign in.", @@ -420,6 +636,15 @@ const LINES = { relayDivergence: "Relay list divergence: {k3Only} relay(s) only in k3, {k10002Only} only in k10002.", unifyRelaysStart: "Merging k3 and k10002 relay lists.", unifyRelaysDone: "Relay lists unified. {count} relay(s) updated with {total} relay(s) each.", + // ── Doctor's Notes ── + doctorNoteClean: "Diagnostic complete. {passCount} checks passed across {relayCount} relay(s). No issues found. No action required.", + doctorNoteMinor: "{warnCount} minor issue(s) detected. {passCount} checks passed. Address when convenient.", + doctorNoteCritical: "{failCount} critical issue(s) and {warnCount} warning(s) detected. Review treatment plan for resolution steps.", + doctorNoteRelayConfig: "Invalid relay URLs detected. Fix relay addresses first β€” other checks depend on valid relay configuration.", + doctorNoteSync: "Profile and contacts out of sync across relays. Rebroadcast to restore consistency.", + doctorNoteMarmot: "Marmot Protocol configuration incomplete. Publish kind 10050 and/or 10051 to enable encrypted messaging.", + doctorNoteKeyPackage: "KeyPackage validation failures per MIP-00/01. Originating client needs to republish corrected KeyPackages.", + doctorNoteMultiple: "{categoryCount} categories of issues detected. Address relay configuration and sync first, then Marmot Protocol setup.", }, house: { intro: "Give me an npub. I'll run a diagnostic. Everybody lies, especially metadata. *leans on cane*", @@ -478,6 +703,42 @@ const LINES = { unifyRelaysDone: "Done. {count} relay(s) updated. k3 and k10002 now agree on " + "{total} relay(s). " + "First time for everything.", + // ── Operating Room ── + orIntro: "Operating Room. Finally, something interesting. *snaps on gloves* Don't flinch.", + orRequiresNip07: "Can't operate without NIP-07. Even I need consent. Usually. *pops vicodin*", + orHealthStart: "Pinging your relays. Let's see which ones are still pretending to work.", + orHealthGood: "All {count} relay(s) alive. Shocking. Usually at least one is dead. Don't get used to it.", + orHealthSlow: "{count} relay(s) dragging their feet. Like my interns. We'll manage.", + orHealthDead: "{count} relay(s) flatlined. Time of death: now. Remove them or pretend they don't exist. Your call.", + orDiscard: "All changes gone. Like they never happened. Story of my life.", + orClose: "Leaving the OR. Try not to break anything while I'm gone. *limps out*", + orOperateStart: "{count} procedure(s). {changes} changes. I'll need your NIP-07 signature. Try not to overthink it.", + orProcedure: "Procedure {index} of {total}: {name}. Hold still.", + orProcedureOk: "{name} β€” {succeeded} relay(s) cooperated. A miracle.", + orProcedureFail: "{name} β€” {failed} relay(s) said no. Of course they did.", + orSuccess: "Surgery complete. {procedures} procedure(s), {relays} relay(s). Everything worked. I'm as surprised as you are. *tosses gloves dramatically*", + orPartial: "{succeeded} worked. {failed} didn't. Like most things in life. You're welcome.", + orFail: "Complete failure. Not a single relay cooperated. Everybody lies. Especially relays.", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "I've already figured out {staged} fix(es) for you. You're welcome. Check below β€” or don't. See if I care.", + orBriefingSuggestions: "{count} thing(s) I can't auto-fix. Shocking. Even geniuses have limits. *pops vicodin*", + orBriefingGuided: "Made you a {steps}-step plan. Because apparently you need hand-holding. Follow it or don't. Your funeral.", + // ── Guided Mode ── + guideStepNote: "Step {step} of {total}. Try not to break anything.", + guideStepAdvance: "Step {step} of {total}. You're actually following instructions. Novel.", + guideStepSkip: "Skipping. Fine. It's not like I spent time on that prescription or anything.", + guideComplete: "We're through the plan. Now review what you've staged and operate. Or don't. But then why are we here?", + guideManualMode: "Manual mode. You think you know better. You probably don't. But go ahead. *leans on cane*", + guideResume: "Back to guided mode. Couldn't handle freedom, could you? *smirks*", + // ── Doctor's Notes ── + doctorNoteClean: "Against all odds, the patient is healthy. {passCount} checks passed across {relayCount} relay(s). Everything works. I'm almost disappointed β€” no puzzles to solve. Almost. *leans back* Discharged.", + doctorNoteMinor: "{warnCount} minor issue(s). Not going to kill you. {passCount} things actually work. Fix the warnings when you feel like pretending to care about your relay health.", + doctorNoteCritical: "{failCount} critical failure(s). {warnCount} warnings. What a mess. You need the operating room. Follow the treatment plan β€” and for once, do exactly what I say.", + doctorNoteRelayConfig: "Your relay URLs are broken. That's like giving people your address but writing it in hieroglyphics. Fix the URLs. Everything else is pointless until you do.", + doctorNoteSync: "Your data is inconsistent across relays. Some have your profile from last year, others from yesterday. Rebroadcast. It's not rocket science. Even you can manage that.", + doctorNoteMarmot: "Marmot Protocol isn't set up. Missing k10050 or k10051 β€” that's like having a phone with no SIM card. Publish the missing events. Or enjoy the silence.", + doctorNoteKeyPackage: "KeyPackages are broken. MIP-00/01 says they're wrong, and MIP-00/01 doesn't lie. Unlike everybody else. Your client needs to fix and republish them. I can't do that from here.", + doctorNoteMultiple: "{categoryCount} different categories of failure. Impressive, really. Start with relay and sync problems β€” everything else depends on those working. Then Marmot. Follow the plan. It's in order for a reason.", }, }; diff --git a/style.css b/style.css index 1e221f6..9c0e888 100644 --- a/style.css +++ b/style.css @@ -69,12 +69,13 @@ body { } #screen-viewport { - /* Fixed design size; scale to fit viewport */ - width: 1366px; + /* Fixed design size; scale to fit viewport. + Smaller design β†’ larger --scale β†’ everything renders bigger. */ + width: 1645px; height: 100ch; flex-shrink: 0; /* Cap at 1 to avoid upscale overflow on ultrawide; scale down on small screens */ - --scale: min(1, 100vw / 1366px, 100vh / 100ch); + --scale: min(1, 100vw / 1645px, 100vh / 100ch); transform: scale(var(--scale)); transform-origin: center center; position: relative; @@ -1125,6 +1126,12 @@ body { background: linear-gradient(180deg, #181868, #3838a8); } +.input-or { + font-size: 0.75ch; + color: var(--c-dim); + flex-shrink: 0; +} + #nip07-btn { font-family: 'Press Start 2P', monospace; font-size: 0.875ch; @@ -1136,17 +1143,57 @@ body { letter-spacing: 0.05ch; white-space: nowrap; transition: background 0.15s, color 0.15s; + flex-shrink: 0; + position: relative; +} + +#nip07-btn:disabled { + opacity: 0.4; + cursor: not-allowed; } -#nip07-btn:hover { +#nip07-btn:not(:disabled):hover { background: linear-gradient(180deg, #3a5a3a, #1a3a1a); color: var(--c-yellow); } -#nip07-btn:active { +#nip07-btn:not(:disabled):active { background: linear-gradient(180deg, #0a2a0a, #2a4a2a); } +#nip07-btn.nip07-ready::after { + content: ''; + position: absolute; + inset: -0.25ch; + border: 0.125ch solid var(--c-green); + animation: nip07-pulse 1.5s ease-in-out infinite; + pointer-events: none; +} + +@keyframes nip07-pulse { + 0%, 100% { opacity: 0; } + 50% { opacity: 0.6; } +} + +#cancel-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.875ch; + padding: 0.625ch 1.25ch; + background: linear-gradient(180deg, #4a2a2a, #2a0a0a); + color: var(--c-red); + border: 0.375ch solid var(--c-red); + cursor: pointer; + letter-spacing: 0.05ch; + white-space: nowrap; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} + +#cancel-btn:hover { + background: linear-gradient(180deg, #5a3a3a, #3a1a1a); + color: var(--c-yellow); +} + #nip07-btn:disabled { background: #111130; color: var(--c-dim); @@ -1280,6 +1327,19 @@ body { color: var(--c-dim); } +/* Dialog scroll-back log entries */ +#dialog-text .dialog-msg { + border-bottom: 0.125ch solid rgba(88, 88, 248, 0.1); + padding-bottom: 0.5ch; + margin-bottom: 0.5ch; +} + +#dialog-text .dialog-msg:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 0; +} + /* typewriter cursor */ #dialog-text::after { content: 'β–Œ'; @@ -1326,106 +1386,1255 @@ body { z-index: 3; } -#scan-bar { - height: 100%; - width: 0%; - background: linear-gradient(90deg, var(--c-border), var(--c-cyan), var(--c-border)); - background-size: 200% 100%; - transition: width 0.4s ease; +#scan-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--c-border), var(--c-cyan), var(--c-border)); + background-size: 200% 100%; + transition: width 0.4s ease; +} + +@keyframes scan-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +#scan-bar.scanning { + animation: scan-shimmer 1.2s linear infinite; +} + +/* ── SHARED ANIMATIONS ── */ +@keyframes blink { + + 0%, + 49% { + opacity: 1; + } + + 50%, + 100% { + opacity: 0; + } +} + +.hidden { + display: none !important; +} + +/* ── SCREEN FLASH ── */ +@keyframes flash-green { + 0% { + box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); + } + + 30% { + box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 10ch 3.75ch rgba(56, 232, 88, 0.5); + } + + 100% { + box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); + } +} + +@keyframes flash-red { + 0% { + box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); + } + + 30% { + box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 10ch 3.75ch rgba(248, 56, 56, 0.55); + } + + 100% { + box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); + } +} + +#game-wrap.flash-ok { + animation: flash-green 0.8s ease-out; +} + +#game-wrap.flash-err { + animation: flash-red 0.8s ease-out; +} + +/* ══════════════════════════════════════════ + FOOTER β€” retro mode link +══════════════════════════════════════════ */ +#retro-footer { + text-align: center; + padding: 1ch 0; + font-size: 0.75ch; + color: var(--c-dim); +} + +#retro-footer a { + color: var(--c-dim); + text-decoration: none; + letter-spacing: 0.1ch; + transition: color 0.2s; +} + +#retro-footer a:hover { + color: var(--c-border2); +} + +#jeff-footer { + display: none; +} + +/* ══════════════════════════════════════════ + OPERATING ROOM β€” SNES RPG Equipment Screen + Beveled menu windows, item slots, cursor + arrows, and that unmistakable 16-bit charm. +══════════════════════════════════════════ */ + +/* ── Keyframes ── */ +@keyframes or-cursor-bob { + 0%, 100% { transform: translateX(0); } + 50% { transform: translateX(0.375ch); } +} + +@keyframes or-heartbeat { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.7; } +} + +@keyframes or-slide-in { + 0% { transform: translateY(0.75ch); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +} + +@keyframes or-staged-glow { + 0%, 100% { box-shadow: inset 0 0 0 0.125ch rgba(56, 232, 88, 0.15); } + 50% { box-shadow: inset 0 0 0 0.125ch rgba(56, 232, 88, 0.4), 0 0 0.75ch rgba(56, 232, 88, 0.15); } +} + +/* ── Editor Container: SNES menu frame ── */ +.or-editor { + display: flex; + flex-direction: column; + gap: 1.25ch; + animation: or-slide-in 0.4s ease-out both; +} + +/* ── Header: ornate title bar like a JRPG submenu ── */ +.or-editor-header { + display: flex; + align-items: center; + gap: 1.5ch; + padding: 1ch 1.75ch; + background: linear-gradient(90deg, rgba(88, 88, 248, 0.12), rgba(0, 0, 80, 0.3)); + border: 0.375ch solid var(--c-border2); + box-shadow: + inset 0 0 0 0.25ch var(--c-shadow), + inset 0 0 0 0.5ch var(--c-border), + 0 0 2ch rgba(160, 160, 255, 0.15); + position: relative; +} + +/* Corner ornaments */ +.or-editor-header::before, +.or-editor-header::after { + content: 'β—†'; + position: absolute; + font-size: 0.75ch; + color: var(--c-yellow); + text-shadow: 0 0 0.75ch var(--c-yellow); +} + +.or-editor-header::before { top: -0.25ch; left: 0.5ch; } +.or-editor-header::after { top: -0.25ch; right: 0.5ch; } + +.or-editor-title { + font-size: 1.25ch; + color: var(--c-yellow); + letter-spacing: 0.375ch; + text-shadow: 0.125ch 0.125ch 0 #000, 0 0 1.5ch rgba(248, 216, 72, 0.5); +} + +.or-editor-subtitle { + font-size: 0.75ch; + color: var(--c-border2); + letter-spacing: 0.125ch; + opacity: 0.8; +} + +/* ── Relay Trays: SNES inventory categories ── */ +.or-tray { + border: 0.25ch solid var(--c-border); + background: rgba(0, 0, 60, 0.5); + box-shadow: + inset 0 0 0 0.125ch var(--c-shadow), + inset 0 0 0 0.375ch rgba(88, 88, 248, 0.12), + 0 0 1.5ch rgba(88, 88, 248, 0.08); + animation: or-slide-in 0.35s ease-out both; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.or-tray:nth-child(2) { animation-delay: 0.05s; } +.or-tray:nth-child(3) { animation-delay: 0.1s; } +.or-tray:nth-child(4) { animation-delay: 0.15s; } +.or-tray:nth-child(5) { animation-delay: 0.2s; } + +.or-tray:hover { + border-color: var(--c-border2); + box-shadow: + inset 0 0 0 0.125ch var(--c-shadow), + inset 0 0 0 0.375ch rgba(88, 88, 248, 0.2), + 0 0 2ch rgba(88, 88, 248, 0.15); +} + +.or-tray-header { + display: flex; + align-items: center; + gap: 1ch; + padding: 0.875ch 1.5ch; + cursor: pointer; + background: linear-gradient(180deg, rgba(88, 88, 248, 0.1) 0%, rgba(0, 0, 40, 0.3) 100%); + border-bottom: 0.125ch solid var(--c-border); + user-select: none; + transition: background 0.15s; + position: relative; +} + +/* Selection cursor arrow β€” the classic SNES β–Ά */ +.or-tray-header::before { + content: 'β–Ά'; + position: absolute; + left: 0.25ch; + font-size: 0.75ch; + color: var(--c-yellow); + text-shadow: 0 0 0.5ch var(--c-yellow); + opacity: 0; + transition: opacity 0.15s; + animation: or-cursor-bob 0.8s ease-in-out infinite; +} + +.or-tray-header:hover::before { + opacity: 1; +} + +.or-tray-header:hover { + background: linear-gradient(180deg, rgba(88, 88, 248, 0.18) 0%, rgba(0, 0, 60, 0.4) 100%); +} + +.or-tray-icon { + font-size: 1.375ch; + line-height: 1; + filter: drop-shadow(0 0.125ch 0.25ch rgba(0, 0, 0, 0.5)); +} + +.or-tray-title { + font-size: 0.875ch; + color: var(--c-cyan); + letter-spacing: 0.25ch; + text-shadow: 0 0 0.75ch var(--c-cyan); + flex: 1; +} + +.or-tray-tag { + font-size: 0.625ch; + color: var(--c-dim); + padding: 0.25ch 0.625ch; + border: 0.125ch solid var(--c-dim); + letter-spacing: 0.05ch; + background: rgba(0, 0, 0, 0.4); +} + +.or-tray-count { + font-size: 0.75ch; + color: var(--c-border2); + text-shadow: 0 0 0.5ch rgba(160, 160, 255, 0.3); +} + +.or-tray-arrow { + font-size: 1ch; + color: var(--c-yellow); + text-shadow: 0 0 0.5ch var(--c-yellow); + transition: transform 0.25s; +} + +.or-tray-collapsed .or-tray-arrow { + transform: rotate(-90deg); +} + +.or-tray-collapsed .or-tray-body { + display: none; +} + +.or-tray-collapsed .or-tray-header { + border-bottom: none; +} + +.or-tray-body { + padding: 0.75ch 1ch; + display: flex; + flex-direction: column; + gap: 0.25ch; + background: linear-gradient(180deg, rgba(0, 0, 30, 0.3), rgba(0, 0, 20, 0.6)); +} + +/* ── Staged Badge: glowing RPG status effect ── */ +.or-staged-badge { + font-size: 0.625ch; + color: var(--c-yellow); + background: rgba(248, 216, 72, 0.12); + border: 0.125ch solid var(--c-yellow); + padding: 0.125ch 0.625ch; + letter-spacing: 0.05ch; + text-shadow: 0 0 0.5ch var(--c-yellow); + animation: or-heartbeat 1.2s ease-in-out infinite; +} + +/* ── Relay Rows: SNES equipment/item slots ── */ +.or-relay-row { + display: grid; + grid-template-columns: 1.5ch 1.125ch 1fr auto auto auto auto; + align-items: center; + gap: 0.75ch; + padding: 0.625ch 1ch; + font-size: 0.875ch; + border: 0.125ch solid rgba(88, 88, 248, 0.15); + background: rgba(0, 0, 40, 0.25); + transition: background 0.15s, border-color 0.2s, box-shadow 0.2s; + position: relative; +} + +/* Alternating row tint for that classic RPG list feel */ +.or-relay-row:nth-child(even) { + background: rgba(0, 0, 60, 0.2); +} + +.or-relay-row:hover { + background: rgba(88, 88, 248, 0.08); + border-color: rgba(88, 88, 248, 0.3); +} + +/* Change icon (+ / βˆ’ ) */ +.or-change-icon { + font-size: 1.125ch; + font-weight: bold; + width: 1.5ch; + text-align: center; + color: transparent; +} + +.or-change-add { + color: var(--c-green); + text-shadow: 0 0 0.5ch var(--c-green); +} + +.or-change-remove { + color: var(--c-red); + text-shadow: 0 0 0.5ch var(--c-red); +} + +/* Status dot: glowing orb like an SNES status indicator */ +.or-relay-dot { + width: 1.125ch; + height: 1.125ch; + border-radius: 50%; + background: var(--c-dim); + border: 0.125ch solid var(--c-relay-border); + box-shadow: + inset 0 0.125ch 0.25ch rgba(255, 255, 255, 0.2), + inset 0 -0.125ch 0.25ch rgba(0, 0, 0, 0.5); + transition: background 0.25s, box-shadow 0.25s; +} + +.or-health-ok .or-relay-dot { + background: var(--c-green); + box-shadow: + inset 0 0.125ch 0.25ch rgba(255, 255, 255, 0.3), + 0 0 0.75ch var(--c-green); +} + +.or-health-warn .or-relay-dot { + background: var(--c-yellow); + box-shadow: + inset 0 0.125ch 0.25ch rgba(255, 255, 255, 0.3), + 0 0 0.75ch var(--c-yellow); + animation: dot-pulse 0.8s ease-in-out infinite; +} + +.or-health-dead .or-relay-dot { + background: var(--c-red); + box-shadow: + inset 0 0.125ch 0.25ch rgba(255, 255, 255, 0.15), + 0 0 0.75ch var(--c-red); +} + +/* URL */ +.or-relay-url { + color: var(--c-border2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.or-staged .or-relay-url { + color: var(--c-green); + text-shadow: 0 0 0.5ch rgba(56, 232, 88, 0.3); +} + +.or-removed .or-relay-url { + color: var(--c-red); + text-decoration: line-through; + opacity: 0.5; +} + +.or-staged { + border-color: var(--c-green); + border-style: dashed; + background: rgba(56, 232, 88, 0.04); + animation: or-staged-glow 2s ease-in-out infinite; +} + +.or-removed { + border-color: var(--c-red); + border-style: dashed; + background: rgba(248, 56, 56, 0.04); +} + +/* Read/Write toggles: SNES toggle buttons */ +.or-rw-toggles { + display: flex; + gap: 0.25ch; +} + +.or-rw-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.625ch; + padding: 0.375ch 0.5ch; + background: linear-gradient(180deg, #181840, #0a0a20); + color: var(--c-dim); + border: 0.125ch solid var(--c-dim); + cursor: pointer; + transition: all 0.15s; + line-height: 1; + position: relative; +} + +/* Beveled shine on active toggles */ +.or-rw-btn.or-rw-active { + color: var(--c-cyan); + border-color: var(--c-cyan); + background: linear-gradient(180deg, #1a3a5a, #0a1a30); + text-shadow: 0 0 0.5ch var(--c-cyan); + box-shadow: 0 0 0.5ch rgba(56, 232, 232, 0.25); +} + +.or-rw-btn.or-rw-active::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, transparent 50%); + pointer-events: none; +} + +.or-rw-btn:hover { + border-color: var(--c-yellow); + color: var(--c-yellow); + text-shadow: 0 0 0.375ch var(--c-yellow); +} + +/* Latency display */ +.or-relay-latency { + font-size: 0.625ch; + color: var(--c-dim); + white-space: nowrap; + min-width: 12ch; + text-align: right; +} + +.or-health-ok .or-relay-latency { color: var(--c-green); } +.or-health-warn .or-relay-latency { color: var(--c-yellow); } +.or-health-dead .or-relay-latency { color: var(--c-red); } + +/* Mini HP bar per relay: SNES-style stat bar */ +.or-relay-hp { + display: flex; + align-items: center; + min-width: 6ch; +} + +.or-hp-track { + width: 100%; + height: 1ch; + background: #0a0a2a; + border: 0.125ch solid var(--c-border); + position: relative; + overflow: hidden; + box-shadow: inset 0 0.125ch 0.25ch rgba(0, 0, 0, 0.6); +} + +.or-hp-fill { + height: 100%; + background: linear-gradient(90deg, #28a838, var(--c-green)); + box-shadow: 0 0 0.5ch var(--c-green); + transition: width 0.4s ease; + position: relative; +} + +/* Shine stripe on HP bar */ +.or-hp-fill::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.25) 0%, transparent 50%); +} + +.or-health-warn .or-hp-fill { + background: linear-gradient(90deg, #c8a020, var(--c-yellow)); + box-shadow: 0 0 0.5ch var(--c-yellow); +} + +.or-health-dead .or-hp-fill { + background: linear-gradient(90deg, #a82020, var(--c-red)); + box-shadow: 0 0 0.5ch var(--c-red); +} + +/* Action buttons: SNES menu buttons with bevel */ +.or-relay-action { + font-family: 'Press Start 2P', monospace; + font-size: 0.75ch; + padding: 0.375ch 0.625ch; + background: linear-gradient(180deg, #282848, #101028); + border: 0.125ch solid var(--c-dim); + color: var(--c-dim); + cursor: pointer; + transition: all 0.15s; + line-height: 1; + position: relative; +} + +.or-relay-action::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, transparent 55%); + pointer-events: none; +} + +.or-remove-btn:hover { + color: var(--c-red); + border-color: var(--c-red); + background: linear-gradient(180deg, #4a2020, #2a0a0a); + box-shadow: 0 0 0.5ch rgba(248, 56, 56, 0.3); +} + +.or-undo-btn { + color: var(--c-yellow); + border-color: var(--c-yellow); + background: linear-gradient(180deg, #3a3a20, #1a1a0a); +} + +.or-undo-btn:hover { + background: linear-gradient(180deg, #4a4a28, #2a2a10); + box-shadow: 0 0 0.5ch rgba(248, 216, 72, 0.3); +} + +/* ── Add Relay: command input like a JRPG text entry ── */ +.or-add-row { + display: flex; + gap: 0.5ch; + margin-top: 0.625ch; + padding-top: 0.625ch; + border-top: 0.125ch solid rgba(88, 88, 248, 0.15); +} + +.or-add-input { + flex: 1; + font-family: 'Press Start 2P', monospace; + font-size: 0.75ch; + padding: 0.625ch 1ch; + background: #000008; + color: var(--c-green); + border: 0.25ch solid var(--c-border); + box-shadow: + inset 0 0.25ch 0.5ch rgba(0, 0, 0, 0.6), + inset 0 0 0 0.125ch rgba(88, 88, 248, 0.08); + caret-color: var(--c-green); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.or-add-input::placeholder { + color: var(--c-dim); +} + +.or-add-input:focus { + border-color: var(--c-border2); + box-shadow: + inset 0 0.25ch 0.5ch rgba(0, 0, 0, 0.6), + 0 0 1ch rgba(160, 160, 255, 0.25); +} + +.or-add-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.75ch; + padding: 0.625ch 1.25ch; + background: linear-gradient(180deg, #2a5a2a, #0a2a0a); + color: var(--c-green); + border: 0.25ch solid var(--c-green); + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + position: relative; + overflow: hidden; +} + +.or-add-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, transparent 50%); + pointer-events: none; +} + +.or-add-btn:hover { + background: linear-gradient(180deg, #3a7a3a, #1a4a1a); + color: var(--c-yellow); + border-color: var(--c-yellow); + box-shadow: 0 0 1.5ch rgba(56, 232, 88, 0.4); +} + +.or-add-btn:active { + background: linear-gradient(180deg, #0a2a0a, #2a5a2a); + transform: translateY(0.125ch); +} + +.or-add-error { + font-size: 0.625ch; + color: var(--c-red); + padding: 0.25ch 1ch; + text-shadow: 0 0 0.5ch rgba(248, 56, 56, 0.4); +} + +.or-empty { + font-size: 0.75ch; + color: var(--c-dim); + padding: 1.5ch; + text-align: center; + border: 0.125ch dashed rgba(88, 88, 248, 0.15); + background: rgba(0, 0, 30, 0.3); +} + +/* ── Bottom Actions: SNES command menu ── */ +.or-actions { + display: flex; + flex-direction: column; + gap: 0.75ch; + padding: 1.25ch 1ch; + border: 0.25ch solid var(--c-border); + background: rgba(0, 0, 60, 0.5); + box-shadow: + inset 0 0 0 0.125ch var(--c-shadow), + inset 0 0 0 0.375ch rgba(88, 88, 248, 0.12); + animation: or-slide-in 0.4s ease-out 0.25s both; +} + +.or-staged-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625ch 1ch; + background: rgba(248, 216, 72, 0.06); + border: 0.25ch solid var(--c-yellow); + box-shadow: + inset 0 0 0 0.125ch rgba(248, 216, 72, 0.1), + 0 0 1ch rgba(248, 216, 72, 0.1); +} + +.or-staged-count { + font-size: 0.75ch; + color: var(--c-yellow); + letter-spacing: 0.05ch; + text-shadow: 0 0 0.5ch var(--c-yellow); +} + +.or-discard-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.625ch; + padding: 0.5ch 1ch; + background: linear-gradient(180deg, #4a1a1a, #2a0808); + color: var(--c-red); + border: 0.25ch solid var(--c-red); + cursor: pointer; + transition: all 0.15s; + position: relative; +} + +.or-discard-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, transparent 50%); + pointer-events: none; +} + +.or-discard-btn:hover { + background: linear-gradient(180deg, #6a2a2a, #3a1010); + box-shadow: 0 0 1ch rgba(248, 56, 56, 0.3); +} + +/* The big OPERATE button: hero button, SNES "CONFIRM" style */ +.or-operate-btn { + font-family: 'Press Start 2P', monospace; + font-size: 1.125ch; + padding: 1ch 2ch; + background: linear-gradient(180deg, #2a6a2a, #0a3a0a); + color: var(--c-green); + border: 0.375ch solid var(--c-green); + cursor: pointer; + letter-spacing: 0.25ch; + white-space: nowrap; + transition: all 0.2s; + position: relative; + overflow: hidden; + text-align: center; + box-shadow: + inset 0 0 0 0.125ch rgba(56, 232, 88, 0.2), + 0 0 1.5ch rgba(56, 232, 88, 0.25), + 0 0.375ch 0 #041a04; +} + +.or-operate-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%); + pointer-events: none; +} + +.or-operate-btn:hover { + background: linear-gradient(180deg, #3a8a3a, #1a5a1a); + color: var(--c-yellow); + border-color: var(--c-yellow); + box-shadow: + inset 0 0 0 0.125ch rgba(248, 216, 72, 0.2), + 0 0 2.5ch rgba(56, 232, 88, 0.4), + 0 0.375ch 0 #041a04; + transform: translateY(-0.125ch); +} + +.or-operate-btn:active { + background: linear-gradient(180deg, #0a3a0a, #2a6a2a); + box-shadow: inset 0 0.25ch 0.5ch rgba(0, 0, 0, 0.5); + transform: translateY(0.25ch); +} + +.or-operate-icon { + font-size: 1.375ch; + filter: drop-shadow(0 0 0.375ch var(--c-green)); +} + +.or-no-changes { + font-size: 0.75ch; + color: var(--c-dim); + text-align: center; + padding: 0.75ch; + border: 0.125ch dashed rgba(88, 88, 248, 0.15); +} + +/* Back button: SNES "B" / cancel */ +.or-close-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.75ch; + padding: 0.625ch 1.25ch; + background: linear-gradient(180deg, #3838a8, #181868); + color: var(--c-border2); + border: 0.25ch solid var(--c-border); + cursor: pointer; + transition: all 0.15s; + text-align: center; + position: relative; + box-shadow: 0 0.25ch 0 #080838; +} + +.or-close-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 55%); + pointer-events: none; +} + +.or-close-btn:hover { + background: linear-gradient(180deg, #5050d0, #282870); + color: var(--c-white); + border-color: var(--c-border2); + box-shadow: 0 0.25ch 0 #080838, 0 0 1ch rgba(160, 160, 255, 0.2); +} + +.or-close-btn:active { + background: linear-gradient(180deg, #181868, #3838a8); + transform: translateY(0.125ch); + box-shadow: none; +} + +/* ── Diff Preview: SNES save-game changelog ── */ +.or-diff-preview { + border: 0.25ch solid var(--c-border); + background: rgba(0, 0, 40, 0.6); + padding: 1ch; + box-shadow: + inset 0 0 0 0.125ch var(--c-shadow), + inset 0 0 0 0.375ch rgba(88, 88, 248, 0.1); +} + +.or-diff-section { + margin-bottom: 0.75ch; +} + +.or-diff-title { + font-size: 0.75ch; + color: var(--c-cyan); + letter-spacing: 0.125ch; + margin-bottom: 0.5ch; + text-shadow: 0 0 0.5ch var(--c-cyan); + padding-bottom: 0.25ch; + border-bottom: 0.0625ch solid rgba(56, 232, 232, 0.2); +} + +.or-diff-row { + font-size: 0.75ch; + padding: 0.25ch 0.75ch; + font-family: 'Press Start 2P', monospace; + line-height: 1.8; +} + +.or-diff-add { + color: var(--c-green); + text-shadow: 0 0 0.375ch rgba(56, 232, 88, 0.3); +} + +.or-diff-remove { + color: var(--c-red); + text-shadow: 0 0 0.375ch rgba(248, 56, 56, 0.3); +} + +.or-diff-modify { + color: var(--c-yellow); + text-shadow: 0 0 0.375ch rgba(248, 216, 72, 0.3); +} + +/* ── Operating Room Button on Patient Chart ── */ +.chart-operate-banner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75ch; + margin-top: 1ch; + padding: 1.25ch; + border: 0.25ch solid var(--c-cyan); + background: linear-gradient(180deg, rgba(56, 232, 232, 0.06), rgba(0, 0, 40, 0.3)); + text-align: center; +} + +/* Legacy class kept for backward compat */ +.chart-operate-row { + display: flex; + align-items: center; + gap: 1.25ch; + margin-top: 1ch; + padding-top: 1ch; + border-top: 0.125ch solid var(--c-border); +} + +.rx-action-operate { + background: linear-gradient(180deg, #1a3a5a, #0a1a3a) !important; + color: var(--c-cyan) !important; + border: 0.25ch solid var(--c-cyan) !important; + font-size: 0.875ch !important; + padding: 0.75ch 1.5ch !important; + letter-spacing: 0.125ch; + box-shadow: + 0 0 1ch rgba(56, 232, 232, 0.2), + 0 0.25ch 0 #040820 !important; +} + +.rx-action-operate::before { + content: '' !important; + position: absolute !important; + inset: 0 !important; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, transparent 50%) !important; + pointer-events: none !important; +} + +.rx-action-operate:hover:not(:disabled) { + background: linear-gradient(180deg, #2a4a6a, #1a2a4a) !important; + color: var(--c-yellow) !important; + border-color: var(--c-yellow) !important; + box-shadow: + 0 0 2ch rgba(56, 232, 232, 0.4), + 0 0.25ch 0 #040820 !important; + transform: translateY(-0.125ch) !important; +} + +.rx-action-operate:active:not(:disabled) { + transform: translateY(0.125ch) !important; + box-shadow: inset 0 0.25ch 0.5ch rgba(0, 0, 0, 0.5) !important; +} + +.chart-operate-hint { + font-size: 0.875ch; + color: var(--c-border2); + line-height: 1.6; +} + +/* ══════════════════════════════════════════ + DOCTOR'S NOTES β€” Post-audit narrative + recommendation from the doctor personality. +══════════════════════════════════════════ */ + +.chart-doctor-notes { + padding: 0.375ch 0.5ch; + margin-top: 0.25ch; +} + +.doctor-note-text { + font-size: 0.625ch; + line-height: 1.5; + color: var(--c-text); + border-left: 2px solid var(--c-dim); + padding-left: 0.5ch; + font-style: italic; +} + +/* ── Per-finding recommendation hints ── */ +.dx-row.dx-has-hint { + flex-wrap: wrap; +} + +.dx-hint { + width: 100%; + font-size: 0.75ch; + color: var(--c-border2); + padding-left: 2.5ch; + margin-top: 0.25ch; + font-style: normal; + line-height: 1.6; + border-left: 0.125ch solid var(--c-dim); +} + +/* ── Status effects grouping ── */ +.dx-group { + margin-bottom: 0.5ch; +} + +.dx-group-label { + font-size: 0.75ch; + letter-spacing: 0.25ch; + padding: 0.5ch 0 0.25ch; + display: flex; + align-items: center; + gap: 0.5ch; +} + +.dx-group-label.dx-fail { color: var(--c-red); } +.dx-group-label.dx-warn { color: var(--c-yellow); } +.dx-group-label.dx-pass { color: var(--c-green); cursor: pointer; } +.dx-group-label.dx-pass:hover { color: var(--c-white); } + +.dx-group-count { + font-size: 0.625ch; + opacity: 0.6; +} + +.dx-group-toggle { + font-size: 0.625ch; + transition: transform 0.15s; +} + +.dx-group-collapsed .dx-group-body { + display: none; +} + +.dx-group-collapsed .dx-group-toggle { + transform: rotate(0deg); +} + +.dx-group:not(.dx-group-collapsed) .dx-group-toggle { + transform: rotate(90deg); +} + +/* ══════════════════════════════════════════ + GUIDED MODE β€” Treatment step card, + progress indicators, and mode toggles + for the step-by-step prescription walker. +══════════════════════════════════════════ */ + +.guide-card { + border: 0.25ch solid var(--c-border); + background: var(--c-panel); + margin-bottom: 0.75ch; + box-shadow: + inset 0 0 0 0.125ch var(--c-shadow), + inset 0 0 0 0.25ch var(--c-border), + 0 0 1ch rgba(56, 232, 232, 0.1); +} + +.guide-card-header { + display: flex; + align-items: center; + gap: 0.5ch; + padding: 0.5ch 0.75ch; + background: linear-gradient(180deg, + rgba(56, 232, 232, 0.15), + rgba(56, 232, 232, 0.05)); + border-bottom: 0.125ch solid var(--c-border); + font-size: 0.875ch; +} + +.guide-card-icon { + font-size: 1.125ch; +} + +.guide-card-title { + font-weight: bold; + letter-spacing: 1px; + color: var(--c-text); + text-transform: uppercase; +} + +.guide-card-progress { + margin-left: auto; + color: var(--c-accent, #38e8e8); + font-weight: bold; +} + +.guide-card-kind { + background: rgba(56, 232, 232, 0.2); + padding: 0.125ch 0.375ch; + border-radius: 2px; + font-size: 0.75ch; + color: var(--c-accent, #38e8e8); +} + +.guide-card-body { + padding: 0.75ch; +} + +.guide-prescription { + display: flex; + align-items: flex-start; + gap: 0.5ch; + font-size: 1ch; + line-height: 1.5; +} + +.guide-rx-text { + flex: 1; + color: var(--c-text); +} + +.guide-tag { + display: inline-block; + font-size: 0.625ch; + padding: 0.125ch 0.375ch; + border-radius: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + flex-shrink: 0; + margin-top: 0.125ch; +} + +.guide-tag-actionable { + background: rgba(100, 255, 100, 0.2); + color: #64ff64; + border: 1px solid rgba(100, 255, 100, 0.3); +} + +.guide-tag-manual { + background: rgba(255, 200, 50, 0.2); + color: #ffc832; + border: 1px solid rgba(255, 200, 50, 0.3); +} + +.guide-tag-client { + background: rgba(255, 100, 100, 0.2); + color: #ff6464; + border: 1px solid rgba(255, 100, 100, 0.3); +} + +.guide-recommendation { + margin-top: 0.5ch; + font-size: 0.875ch; + color: var(--c-border2); + border-left: 0.25ch solid var(--c-dim); + padding-left: 0.5ch; + line-height: 1.6; + font-style: normal; +} + +.guide-doctor-note { + margin-top: 0.375ch; + font-size: 0.75ch; + color: var(--c-accent, #38e8e8); + opacity: 0.7; +} + +.guide-card-actions { + display: flex; + gap: 0.5ch; + padding: 0.5ch 0.75ch; + border-top: 0.125ch solid var(--c-border); +} + +.guide-btn { + font-family: inherit; + font-size: 0.75ch; + padding: 0.375ch 0.75ch; + border: 0.125ch solid var(--c-border); + background: var(--c-shadow); + color: var(--c-text); + cursor: pointer; + letter-spacing: 0.5px; + transition: all 0.15s ease; +} + +.guide-btn:hover { + background: var(--c-border); + color: var(--c-bg); +} + +.guide-btn-next { + margin-left: auto; + background: rgba(56, 232, 232, 0.15); + border-color: var(--c-accent, #38e8e8); + color: var(--c-accent, #38e8e8); } -@keyframes scan-shimmer { - 0% { - background-position: 200% 0; - } +.guide-btn-next:hover { + background: var(--c-accent, #38e8e8); + color: var(--c-bg); +} - 100% { - background-position: -200% 0; - } +.guide-btn-fix { + background: rgba(56, 232, 88, 0.2); + border-color: var(--c-green); + color: var(--c-green); + font-weight: bold; + letter-spacing: 1px; } -#scan-bar.scanning { - animation: scan-shimmer 1.2s linear infinite; +.guide-btn-fix:hover:not(:disabled) { + background: var(--c-green); + color: var(--c-bg); } -/* ── SHARED ANIMATIONS ── */ -@keyframes blink { +.guide-btn-fix:disabled { + opacity: 0.4; + cursor: not-allowed; +} - 0%, - 49% { - opacity: 1; - } +.guide-btn-skip { + opacity: 0.7; +} - 50%, - 100% { - opacity: 0; - } +.guide-btn-skip:hover { + opacity: 1; } -.hidden { - display: none !important; +.guide-btn-manual { + font-size: 0.625ch; + opacity: 0.6; } -/* ── SCREEN FLASH ── */ -@keyframes flash-green { - 0% { - box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); - } +.guide-btn-manual:hover { + opacity: 1; +} - 30% { - box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 10ch 3.75ch rgba(56, 232, 88, 0.5); - } +/* ── Guide resume bar ── */ +.guide-resume-bar { + position: sticky; + top: 0; + z-index: 5; + padding: 0.5ch; + background: rgba(0, 0, 40, 0.95); + border-bottom: 0.25ch solid var(--c-accent, #38e8e8); + backdrop-filter: blur(4px); +} - 100% { - box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); - } +.guide-btn-resume { + font-family: inherit; + font-size: 0.75ch; + padding: 0.5ch 1ch; + border: 0.25ch solid var(--c-accent, #38e8e8); + background: rgba(56, 232, 232, 0.08); + color: var(--c-accent, #38e8e8); + cursor: pointer; + width: 100%; + text-align: center; + letter-spacing: 0.5px; + transition: all 0.15s ease; + animation: guide-pulse 2s ease-in-out infinite; } -@keyframes flash-red { - 0% { - box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); - } +.guide-btn-resume:hover { + background: rgba(56, 232, 232, 0.2); + color: var(--c-white); +} - 30% { - box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 10ch 3.75ch rgba(248, 56, 56, 0.55); - } +@keyframes guide-pulse { + 0%, 100% { box-shadow: 0 0 0.5ch rgba(56, 232, 232, 0.2); } + 50% { box-shadow: 0 0 1.5ch rgba(56, 232, 232, 0.4); } +} - 100% { - box-shadow: inset 0 0 0 0.125ch var(--c-shadow), 0 0 7.5ch rgba(88, 88, 248, 0.35); - } +/* ── Tray highlight for guided mode ── */ +.or-tray-highlighted { + box-shadow: + 0 0 8px rgba(56, 232, 232, 0.3), + inset 0 0 4px rgba(56, 232, 232, 0.1); + border-color: var(--c-accent, #38e8e8) !important; } -#game-wrap.flash-ok { - animation: flash-green 0.8s ease-out; +.or-tray-highlighted > .or-tray-header { + background: linear-gradient(180deg, + rgba(56, 232, 232, 0.15), + rgba(56, 232, 232, 0.05)); } -#game-wrap.flash-err { - animation: flash-red 0.8s ease-out; +/* ── Relay rows collapse (after chart renders) ── */ +.relay-rows-collapsed .relay-row, +.relay-rows-collapsed .result-section { + display: none; } -/* ══════════════════════════════════════════ - FOOTER β€” retro mode link -══════════════════════════════════════════ */ -#retro-footer { - text-align: center; - padding: 1ch 0; - font-size: 0.75ch; - color: var(--c-dim); +.relay-rows-collapsed.relay-rows-expanded .relay-row, +.relay-rows-collapsed.relay-rows-expanded .result-section { + display: flex; } -#retro-footer a { - color: var(--c-dim); - text-decoration: none; - letter-spacing: 0.1ch; - transition: color 0.2s; +.relay-rows-collapsed.relay-rows-expanded .result-section { + display: block; } -#retro-footer a:hover { - color: var(--c-border2); +.relay-collapse-toggle { + font-family: 'Press Start 2P', monospace; + font-size: 0.75ch; + color: var(--c-dim); + background: none; + border: 0.125ch solid var(--c-dim); + padding: 0.5ch 1ch; + cursor: pointer; + width: 100%; + text-align: center; + margin-bottom: 0.5ch; + transition: all 0.15s ease; } -#jeff-footer { - display: none; +.relay-collapse-toggle:hover { + color: var(--c-accent, #38e8e8); + border-color: var(--c-accent, #38e8e8); } /* ══════════════════════════════════════════ @@ -1751,4 +2960,260 @@ body { height: 3px; } -} \ No newline at end of file + /* ── Operating Room mobile ── */ + .or-editor-header { + padding: 8px 10px; + border-width: 2px; + box-shadow: + inset 0 0 0 1px var(--c-shadow), + inset 0 0 0 2px var(--c-border); + } + + .or-editor-header::before, + .or-editor-header::after { + font-size: 6px; + } + + .or-editor-title { + font-size: 10px; + letter-spacing: 2px; + } + + .or-editor-subtitle { + font-size: 7px; + } + + .or-tray { + border-width: 2px; + } + + .or-tray-header { + padding: 8px 10px; + gap: 6px; + } + + .or-tray-header::before { + left: 2px; + font-size: 6px; + } + + .or-tray-icon { + font-size: 11px; + } + + .or-tray-title { + font-size: 8px; + letter-spacing: 1px; + } + + .or-tray-tag { + font-size: 6px; + padding: 1px 4px; + } + + .or-tray-count { + font-size: 7px; + } + + .or-tray-arrow { + font-size: 9px; + } + + .or-tray-body { + padding: 6px 8px; + } + + .or-relay-row { + grid-template-columns: 12px 8px 1fr auto auto; + gap: 4px; + padding: 5px 8px; + font-size: 8px; + border-width: 1px; + flex-wrap: wrap; + } + + .or-relay-latency { + display: block; + font-size: 7px; + width: 100%; + padding-left: 20px; + margin-top: 2px; + } + + .or-relay-hp { + display: flex; + width: 100%; + padding-left: 20px; + margin-top: 2px; + min-width: 60px; + } + + .or-relay-dot { + width: 8px; + height: 8px; + } + + .or-rw-toggles { + gap: 2px; + } + + .or-rw-btn { + font-size: 6px; + padding: 2px 4px; + border-width: 1px; + } + + .or-relay-action { + font-size: 7px; + padding: 3px 5px; + border-width: 1px; + } + + .or-add-row { + gap: 4px; + } + + .or-add-input { + font-size: 8px; + padding: 7px 8px; + border-width: 2px; + } + + .or-add-btn { + font-size: 7px; + padding: 7px 10px; + border-width: 2px; + } + + .or-add-error { + font-size: 6px; + } + + .or-actions { + padding: 10px 8px; + gap: 6px; + border-width: 2px; + } + + .or-staged-summary { + padding: 6px 8px; + border-width: 2px; + } + + .or-staged-count { + font-size: 7px; + } + + .or-staged-badge { + font-size: 6px; + } + + .or-discard-btn { + font-size: 6px; + padding: 4px 8px; + border-width: 2px; + } + + .or-operate-btn { + font-size: 10px; + padding: 10px 16px; + border-width: 2px; + letter-spacing: 1px; + box-shadow: + inset 0 0 0 1px rgba(56, 232, 88, 0.2), + 0 0 8px rgba(56, 232, 88, 0.25), + 0 2px 0 #041a04; + } + + .or-close-btn { + font-size: 7px; + padding: 6px 10px; + border-width: 2px; + box-shadow: 0 2px 0 #080838; + } + + .or-diff-preview { + border-width: 2px; + padding: 8px; + } + + .or-diff-title { + font-size: 7px; + } + + .or-diff-row { + font-size: 7px; + padding: 2px 6px; + } + + .chart-operate-row { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .rx-action-operate { + font-size: 8px !important; + padding: 8px 12px !important; + border-width: 2px !important; + box-shadow: + 0 0 6px rgba(56, 232, 232, 0.2), + 0 2px 0 #040820 !important; + } + + .chart-operate-hint { + font-size: 6px; + text-align: center; + } + + /* ── Doctor's Notes mobile ── */ + .doctor-note-text { + font-size: 9px; + } + + .dx-hint { + font-size: 7px; + padding-left: 20px; + } + + /* ── Guided mode mobile ── */ + .guide-card-header { + font-size: 9px; + padding: 6px 8px; + } + + .guide-card-body { + padding: 8px; + } + + .guide-prescription { + font-size: 9px; + flex-wrap: wrap; + } + + .guide-tag { + font-size: 6px; + } + + .guide-recommendation { + font-size: 8px; + } + + .guide-card-actions { + padding: 6px 8px; + flex-wrap: wrap; + } + + .guide-btn { + font-size: 7px; + padding: 4px 8px; + } + + .guide-resume-bar { + padding: 4px 8px; + } + + .guide-btn-resume { + font-size: 7px; + } +} From 5ddd1ac159cbca8c6b04ad480991561e442f57bb Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 4 Mar 2026 12:42:41 -0300 Subject: [PATCH 2/6] feat: add copy-paste nak CLI commands for non-NIP-07 users --- js/audit.js | 21 +++++ js/audit/chartRender.js | 27 ++++++- js/nak-commands.js | 166 ++++++++++++++++++++++++++++++++++++++++ js/operate/guide.js | 22 +++++- js/operate/room.js | 18 ++++- style.css | 95 +++++++++++++++++++++++ 6 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 js/nak-commands.js diff --git a/js/audit.js b/js/audit.js index 2c342b0..6a5a121 100644 --- a/js/audit.js +++ b/js/audit.js @@ -384,6 +384,7 @@ async function runCompileAndRenderPhase(rawNpub, compileParams, kpEventsCollecte canOperate, rawNpub, doctorNotes: compiled.doctorNotes, + auditState: lastAuditState, }, getDisplayName, speak); // Collapse relay status rows and result sections behind an expandable summary @@ -413,6 +414,26 @@ async function runCompileAndRenderPhase(rawNpub, compileParams, kpEventsCollecte const chartEl = cardContainer.firstElementChild; relayList.appendChild(chartEl); + // Wire up nak toggle and copy buttons + chartEl.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + if (action === 'toggle-nak') { + const wrap = btn.closest('.rx-row-wrap'); + const container = wrap?.querySelector('.rx-nak-container'); + if (container) container.classList.toggle('hidden'); + } else if (action === 'copy-nak') { + const pre = btn.closest('.nak-cmd-block')?.querySelector('.nak-cmd-pre code'); + if (pre) { + navigator.clipboard.writeText(pre.textContent).then(() => { + btn.textContent = 'COPIED βœ”'; + setTimeout(() => { btn.textContent = 'COPY'; }, 2000); + }); + } + } + }); + // Wire up collapsible pass-group toggle in STATUS EFFECTS const passGroupToggle = chartEl.querySelector('[data-action="toggle-dx-group"]'); if (passGroupToggle) { diff --git a/js/audit/chartRender.js b/js/audit/chartRender.js index 8420907..6638a45 100644 --- a/js/audit/chartRender.js +++ b/js/audit/chartRender.js @@ -3,6 +3,7 @@ */ import { html, escapeHtml } from '../html.js'; +import { getNakCommand, renderNakCommandBlock } from '../nak-commands.js'; const NPUB_SHORT_LEN = 20; @@ -128,7 +129,7 @@ function resolvePrescriptionAction(rx) { return null; } -function buildTreatmentSection(prescriptions, actionFlags, canOperate) { +function buildTreatmentSection(prescriptions, actionFlags, canOperate, auditState) { if (prescriptions.length === 0 && !canOperate) return ''; const { @@ -166,10 +167,27 @@ function buildTreatmentSection(prescriptions, actionFlags, canOperate) { `; } + // Generate nak command for this action + let nakBlock = ''; + if (action && action !== 'reduce-relays') { + const nakCmd = getNakCommand(action, auditState); + if (nakCmd) { + nakBlock = html` +
+ +
+ + `; + } + } + return html` -
- ${rxText} - ${actionButton} +
+
+ ${rxText} + ${actionButton} +
+ ${nakBlock}
`; }); @@ -271,6 +289,7 @@ export function renderChart(params, getDisplayName, speakFn) { prescriptions, { canRebroadcast, canDeleteKps, canUnifyRelays, canDeleteOrphanedKps, canDeleteKind4 }, canOperate, + params.auditState, )} ${buildChartSignature(doctorName)}
diff --git a/js/nak-commands.js b/js/nak-commands.js new file mode 100644 index 0000000..28f0c8d --- /dev/null +++ b/js/nak-commands.js @@ -0,0 +1,166 @@ +/** + * Generate copy-paste nak (nostr army knife) commands for each fixable prescription. + * These let users without NIP-07 fix their relay setup from the terminal. + * + * nak CLI reference: https://github.com/fiatjaf/nak + * nak event -k --sec --tag β€” create+publish + * nak req -k -a | nak event β€” rebroadcast + */ + +import { escapeHtml } from './html.js'; + +/** + * Generate a nak command string for a given prescription action. + * + * @param {string} action - One of: rebroadcast, delete-kps, delete-orphaned-kps, unify-relays, delete-kind4 + * @param {Object} state - The audit state from getAuditState() + * @returns {{ cmd: string, description: string }|null} The nak command and a human-readable description, or null if not applicable + */ +export function getNakCommand(action, state) { + if (!state) return null; + + const { pubkey, relaysToInvestigate } = state; + if (!pubkey) return null; + + const relayArgs = (relaysToInvestigate || []).join(' '); + const sourceRelay = relaysToInvestigate?.[0] || 'wss://relay.damus.io'; + + switch (action) { + case 'rebroadcast': + return buildRebroadcastCmd(pubkey, sourceRelay, relayArgs); + case 'delete-kps': + return buildDeleteKpsCmd(state, relayArgs); + case 'delete-orphaned-kps': + return buildDeleteOrphanedKpsCmd(state, relayArgs); + case 'unify-relays': + return buildUnifyCmd(state, relayArgs); + case 'delete-kind4': + return buildDeleteKind4Cmd(pubkey, relayArgs); + default: + return null; + } +} + +function buildRebroadcastCmd(pubkey, sourceRelay, relayArgs) { + const cmd = `# Rebroadcast profile (k0) and contacts (k3) to all relays\n` + + `nak req -k 0 -k 3 -a ${pubkey} ${sourceRelay} | \\\n` + + ` nak event ${relayArgs}`; + + return { + cmd, + description: 'Fetches your latest kind 0 + kind 3 from the first relay and republishes them to all your relays.', + }; +} + +function buildDeleteKpsCmd(state, relayArgs) { + const { missingITagIds } = state; + if (!missingITagIds || missingITagIds.length === 0) return null; + + const eTags = missingITagIds.map(id => '-e ' + id).join(' \\\n '); + const cmd = '# Delete orphaned/invalid KeyPackages (kind 5 deletion)\n' + + 'nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n' + + ' ' + eTags + ' \\\n' + + " -c 'KeyPackage cleanup' \\\n" + + ' ' + relayArgs; + + return { + cmd, + description: 'Signs kind 5 deletion events for ' + missingITagIds.length + ' KeyPackage(s) and publishes to all relays. Set NOSTR_SECRET_KEY first.', + }; +} + +function buildDeleteOrphanedKpsCmd(state, relayArgs) { + const { orphanedKpRelays } = state; + if (!orphanedKpRelays || orphanedKpRelays.length === 0) return null; + + const orphanRelayArgs = orphanedKpRelays.join(' '); + const pk = state.pubkey; + const cmd = '# Step 1: Find KeyPackage IDs on orphaned relays\n' + + 'nak req -k 443 -a ' + pk + ' ' + orphanRelayArgs + '\n\n' + + '# Step 2: Delete them (replace with IDs from step 1)\n' + + 'nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n' + + ' -e \\\n' + + " -c 'Orphaned KP cleanup' \\\n" + + ' ' + orphanRelayArgs + ' ' + relayArgs; + + return { + cmd, + description: 'Queries ' + orphanedKpRelays.length + ' orphaned relay(s) for kind 443 events, then deletes them. Two-step: find IDs first, then delete.', + }; +} + +function buildUnifyCmd(state, relayArgs) { + const { k3RelaySet, k10002RelaySet, bestK3 } = state; + if (!k3RelaySet && !k10002RelaySet) return null; + + // Merge both sets + const merged = [...new Set([...(k3RelaySet || []), ...(k10002RelaySet || [])])]; + if (merged.length === 0) return null; + + // Build k10002 r-tags + const rTags = merged.map(r => '--tag r=' + r).join(' \\\n '); + + // Build k3 relay tags + preserve p-tags + const relayTags = merged.map(r => '--tag relay=' + r).join(' \\\n '); + const pTags = (bestK3?.tags || []) + .filter(t => Array.isArray(t) && t[0] === 'p' && t[1]) + .map(t => '-p ' + t[1]) + .join(' \\\n '); + + const cmd = '# Publish unified kind 10002 (NIP-65 relay list)\n' + + 'nak event -k 10002 --sec $NOSTR_SECRET_KEY \\\n' + + ' ' + rTags + ' \\\n' + + " -c '' \\\n" + + ' ' + relayArgs + '\n\n' + + '# Publish unified kind 3 (contacts)\n' + + 'nak event -k 3 --sec $NOSTR_SECRET_KEY \\\n' + + (pTags ? ' ' + pTags + ' \\\n ' : ' ') + + relayTags + ' \\\n' + + " -c '" + escapeShellContent(bestK3?.content || '') + "' \\\n" + + ' ' + relayArgs; + + return { + cmd, + description: 'Publishes a new kind 10002 and kind 3 with ' + merged.length + ' unified relay(s). Both events will advertise the same set.', + }; +} + +function buildDeleteKind4Cmd(pubkey, relayArgs) { + const cmd = '# Step 1: Find all kind 4 (NIP-04 DM) event IDs\n' + + 'nak req -k 4 -a ' + pubkey + ' ' + relayArgs + '\n\n' + + '# Step 2: Delete them (pipe IDs or list them manually)\n' + + 'nak req -k 4 -a ' + pubkey + ' ' + relayArgs + ' | \\\n' + + " jq -r '.id' | \\\n" + + ' xargs -I {} nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n' + + ' -e {} \\\n' + + " -c 'NIP-04 DM cleanup β€” migrating to NIP-17/Marmot' \\\n" + + ' ' + relayArgs; + + return { + cmd, + description: 'Scans for kind 4 events and publishes kind 5 deletions for each. Requires jq and xargs.', + }; +} + +function escapeShellContent(str) { + // Escape single quotes for shell: replace ' with '\'' + return str.replace(/'/g, "'\\''"); +} + +/** + * Render a nak command block as HTML with a copy button. + * @param {{ cmd: string, description: string }} nakCmd + * @returns {string} HTML string + */ +export function renderNakCommandBlock(nakCmd) { + if (!nakCmd) return ''; + + return `
` + + `
` + + `πŸ”§ nak command` + + `` + + `
` + + `
${escapeHtml(nakCmd.description)}
` + + `
${escapeHtml(nakCmd.cmd)}
` + + `
`; +} diff --git a/js/operate/guide.js b/js/operate/guide.js index f70e76d..4c08f89 100644 --- a/js/operate/guide.js +++ b/js/operate/guide.js @@ -6,6 +6,7 @@ import { html, escapeHtml } from '../html.js'; import { speak } from '../personalities.js'; +import { getNakCommand, renderNakCommandBlock } from '../nak-commands.js'; /** * @typedef {Object} GuideStep @@ -21,6 +22,7 @@ import { speak } from '../personalities.js'; let steps = []; let currentStep = -1; let isGuided = false; +let storedAuditState = null; /** * Whether guided mode is active. @@ -59,9 +61,11 @@ export function getTotalSteps() { * Initialize the guided flow from prescriptions. * @param {string[]} prescriptions - From compileFindingsAndPrescriptions * @param {Object} _findings - The findings object with pass/warn/fail arrays (reserved for future step enrichment) + * @param {Object} [auditState] - Audit state for generating nak commands * @returns {number} Total steps */ -export function initGuide(prescriptions, _findings) { +export function initGuide(prescriptions, _findings, auditState) { + storedAuditState = auditState || null; steps = prescriptions.map((rx, index) => ({ index, prescription: rx, @@ -104,6 +108,7 @@ export function exitGuide() { isGuided = false; currentStep = -1; steps = []; + storedAuditState = null; } /** @@ -157,6 +162,20 @@ export function renderGuideCard(step) { ` : ''; + // Generate nak command block for this step + let nakSection = ''; + if (step.fixAction && storedAuditState) { + const nakCmd = getNakCommand(step.fixAction, storedAuditState); + if (nakCmd) { + nakSection = html` +
+ +
+ + `; + } + } + return html`
@@ -181,6 +200,7 @@ export function renderGuideCard(step) {
+ ${nakSection}
`; } diff --git a/js/operate/room.js b/js/operate/room.js index e6eea27..c89b5d5 100644 --- a/js/operate/room.js +++ b/js/operate/room.js @@ -116,7 +116,7 @@ export async function enterOperatingRoom() { } // Initialize guided mode - initGuide(compiled.prescriptions, compiled.findings); + initGuide(compiled.prescriptions, compiled.findings, state); if (getTotalSteps() > 0) { await say(speak('orBriefingGuided', { steps: getTotalSteps() })); } @@ -296,6 +296,7 @@ async function handleEditorClick(e) { 'toggle-tray', 'add-relay', 'remove-relay', 'undo-remove', 'toggle-rw', 'discard-all', 'commit-operate', 'close-or', 'guide-next', 'guide-skip', 'guide-manual', 'guide-resume', 'guide-fix', + 'toggle-nak', 'copy-nak', ]; if (!OR_ACTIONS.includes(action)) return; @@ -409,6 +410,21 @@ async function handleEditorClick(e) { renderRoom(); break; } + case 'toggle-nak': { + const container = btn.closest('.guide-card, .rx-row-wrap')?.querySelector('.guide-nak-container, .rx-nak-container'); + if (container) container.classList.toggle('hidden'); + break; + } + case 'copy-nak': { + const pre = btn.closest('.nak-cmd-block')?.querySelector('.nak-cmd-pre code'); + if (pre) { + navigator.clipboard.writeText(pre.textContent).then(() => { + btn.textContent = 'COPIED βœ”'; + setTimeout(() => { btn.textContent = 'COPY'; }, 2000); + }); + } + break; + } case 'guide-fix': { const fixAction = btn.dataset.fixAction; if (!fixAction) break; diff --git a/style.css b/style.css index 9c0e888..e0960a6 100644 --- a/style.css +++ b/style.css @@ -2289,6 +2289,101 @@ body { line-height: 1.6; } +/* ══════════════════════════════════════════ + NAK COMMANDS β€” Copy-paste CLI commands +══════════════════════════════════════════ */ + +.rx-row-wrap { + /* Wrapper for prescription row + nak command */ +} + +.rx-nak-toggle, +.guide-nak-toggle { + padding: 0.125ch 0 0.25ch 1.5ch; +} + +.nak-toggle-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.5ch; + padding: 0.125ch 0.5ch; + background: rgba(88, 88, 248, 0.1); + border: 0.125ch solid var(--c-dim); + color: var(--c-dim); + cursor: pointer; + transition: all 0.15s ease; +} + +.nak-toggle-btn:hover { + color: var(--c-accent, #38e8e8); + border-color: var(--c-accent, #38e8e8); + background: rgba(56, 232, 232, 0.1); +} + +.rx-nak-container, +.guide-nak-container { + padding: 0 0 0.5ch 1.5ch; +} + +.nak-cmd-block { + border: 0.125ch solid var(--c-dim); + background: rgba(0, 0, 20, 0.6); + margin-top: 0.25ch; +} + +.nak-cmd-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.25ch 0.5ch; + background: rgba(88, 88, 248, 0.08); + border-bottom: 0.0625ch solid var(--c-dim); +} + +.nak-cmd-label { + font-size: 0.625ch; + color: var(--c-accent, #38e8e8); + letter-spacing: 0.5px; +} + +.nak-copy-btn { + font-family: 'Press Start 2P', monospace; + font-size: 0.5ch; + padding: 0.125ch 0.5ch; + background: rgba(56, 232, 232, 0.15); + border: 0.125ch solid var(--c-accent, #38e8e8); + color: var(--c-accent, #38e8e8); + cursor: pointer; + transition: all 0.15s ease; +} + +.nak-copy-btn:hover { + background: var(--c-accent, #38e8e8); + color: var(--c-bg); +} + +.nak-cmd-desc { + font-size: 0.5ch; + color: var(--c-border2); + padding: 0.375ch 0.5ch; + line-height: 1.5; +} + +.nak-cmd-pre { + padding: 0.5ch; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: var(--c-border) transparent; +} + +.nak-cmd-pre code { + font-family: 'Press Start 2P', monospace; + font-size: 0.5ch; + color: var(--c-green); + line-height: 1.8; + white-space: pre-wrap; + word-break: break-all; +} + /* ══════════════════════════════════════════ DOCTOR'S NOTES β€” Post-audit narrative recommendation from the doctor personality. From 10b27087c236401016cfdf68122143d8c954f27a Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 4 Mar 2026 13:05:57 -0300 Subject: [PATCH 3/6] carrots --- js/actions.js | 61 ++++++++++++++++++++++--------- js/audit/chartRender.js | 4 +- js/audit/findingsPrescriptions.js | 10 ++--- js/dialog.js | 19 ++++++++-- js/dom.js | 14 ++++++- js/nak-commands.js | 22 ++++++++--- js/operate/editor.js | 12 +++++- js/operate/guide.js | 3 +- js/operate/health.js | 5 ++- js/operate/nip11.js | 13 +++++-- js/operate/procedures.js | 5 +-- js/operate/room.js | 31 +++++++++++----- style.css | 8 ++-- 13 files changed, 146 insertions(+), 61 deletions(-) diff --git a/js/actions.js b/js/actions.js index 5ccb8ac..8edaaf5 100644 --- a/js/actions.js +++ b/js/actions.js @@ -443,32 +443,59 @@ export async function deleteDeprecatedKind4() { const pool = new SimplePool(); const kind4Ids = new Set(); + const PAGE_SIZE = 500; for (const relay of relaysToInvestigate) { setRelayState(relay, 'connecting', 'SCANNING'); try { - const events = await new Promise((resolve) => { - const collected = []; - let done = false; - const sub = pool.subscribeMany([relay], { authors: [pubkey], kinds: [4], limit: 500 }, { - onevent(ev) { collected.push(ev); }, - oneose() { + let totalFound = 0; + let until = Math.floor(Date.now() / 1000) + 60; + let pageCount = 0; + const MAX_PAGES = 20; // Safety cap: 10,000 events max + + // Paginate using `until` cursor + while (true) { + if (pageCount >= MAX_PAGES) break; + const page = await new Promise((resolve) => { + const collected = []; + let done = false; + const sub = pool.subscribeMany( + [relay], + { authors: [pubkey], kinds: [4], limit: PAGE_SIZE, until }, + { + onevent(ev) { collected.push(ev); }, + oneose() { + if (done) return; + done = true; + try { sub.close(); } catch { /* ignore */ } + resolve(collected); + }, + }, + ); + setTimeout(() => { if (done) return; done = true; try { sub.close(); } catch { /* ignore */ } resolve(collected); - }, + }, RELAY_TIMEOUT_MS); }); - setTimeout(() => { - if (done) return; - done = true; - try { sub.close(); } catch { /* ignore */ } - resolve(collected); - }, RELAY_TIMEOUT_MS); - }); - for (const ev of events) { - if (ev.id) kind4Ids.add(ev.id); + + for (const ev of page) { + if (ev.id) kind4Ids.add(ev.id); + } + totalFound += page.length; + pageCount++; + + // If page returned fewer than PAGE_SIZE, we have all events + if (page.length < PAGE_SIZE) break; + + // Set cursor to oldest event in this page for next iteration + const oldest = page.reduce((min, ev) => + ev.created_at < min ? ev.created_at : min, page[0].created_at); + until = oldest - 1; } - setRelayState(relay, 'ok', `${events.length} k4`); + + const cappedNote = pageCount >= MAX_PAGES ? ' (capped)' : ''; + setRelayState(relay, 'ok', totalFound + ' k4' + cappedNote); } catch (e) { console.error('Failed to query relay for kind 4', relay, e); setRelayState(relay, 'error', 'FAIL'); diff --git a/js/audit/chartRender.js b/js/audit/chartRender.js index 6638a45..46e7e61 100644 --- a/js/audit/chartRender.js +++ b/js/audit/chartRender.js @@ -79,7 +79,7 @@ function buildEffectGroup(groupLabel, groupClass, items) { return html`
${icon} - ${effect.text} + ${escapeHtml(effect.text)} ${hintRow}
`; @@ -157,7 +157,7 @@ function buildTreatmentSection(prescriptions, actionFlags, canOperate, auditStat actionButton = html` `; - } else if (action === 'reduce-relays') { + } else if (action === 'reduce-relays' && canOperate) { actionButton = html` `; diff --git a/js/audit/findingsPrescriptions.js b/js/audit/findingsPrescriptions.js index 5a5e8ad..bd5f035 100644 --- a/js/audit/findingsPrescriptions.js +++ b/js/audit/findingsPrescriptions.js @@ -112,7 +112,7 @@ export function generateDoctorNotes(params) { noteKey = 'doctorNoteRelayConfig'; } else if (hasSync && !hasRelayConfig && !hasMarmot && !hasKp) { noteKey = 'doctorNoteSync'; - } else if (hasMarmot && !hasRelayConfig && !hasSync) { + } else if (hasMarmot && !hasRelayConfig && !hasSync && !hasKp) { noteKey = 'doctorNoteMarmot'; } else if (hasKp && !hasRelayConfig && !hasSync && !hasMarmot) { noteKey = 'doctorNoteKeyPackage'; @@ -609,10 +609,10 @@ export function generatePrescriptions( const uniqueRx = [...new Set(prescriptions)].sort( (a, b) => prescriptionPriority(a) - prescriptionPriority(b), ); - const canRebroadcast = (staleRelays > 0 || missingRelays > 0); - const canDeleteKps = missingITagIds.length > 0; - const canDeleteOrphanedKps = orphanedKpRelays && orphanedKpRelays.length > 0; - const canDeleteKind4 = auditCtx.hasDeprecatedK4; + const canRebroadcast = Boolean(staleRelays > 0 || missingRelays > 0); + const canDeleteKps = Boolean(missingITagIds.length > 0); + const canDeleteOrphanedKps = Boolean(orphanedKpRelays && orphanedKpRelays.length > 0); + const canDeleteKind4 = Boolean(auditCtx.hasDeprecatedK4); return { prescriptions: uniqueRx, canRebroadcast, canDeleteKps, canDeleteOrphanedKps, canDeleteKind4 }; } diff --git a/js/dialog.js b/js/dialog.js index 3d9e9c0..81ba131 100644 --- a/js/dialog.js +++ b/js/dialog.js @@ -45,10 +45,19 @@ export function say(text, onDone = null) { }); } -function _appendToLog(content) { +/** + * Append a message entry to the dialog log. + * @param {string} content - Text or pre-sanitized HTML + * @param {boolean} isHtml - If true, render as innerHTML (caller must pre-sanitize); otherwise textContent + */ +function _appendToLog(content, isHtml = false) { const entry = document.createElement('div'); entry.className = 'dialog-msg'; - entry.innerHTML = content; + if (isHtml) { + entry.innerHTML = content; + } else { + entry.textContent = content; + } dialogText.appendChild(entry); dialogText.scrollTop = dialogText.scrollHeight; } @@ -69,13 +78,15 @@ function _nextMsg() { const { text, onDone } = msgQueue[0]; if (text.includes('<')) { - _appendToLog(text); + // Pre-formatted HTML from speak() β€” callers are responsible for sanitizing + // untrusted values via escapeHtml() before they reach this path. + _appendToLog(text, true); _finishMsg(onDone); return; } if (isJeff) { - _appendToLog(text); + _appendToLog(text); // plain text β€” safe textContent path _finishMsg(onDone); return; } diff --git a/js/dom.js b/js/dom.js index 5574264..2f4035c 100644 --- a/js/dom.js +++ b/js/dom.js @@ -26,7 +26,7 @@ export function getRelayRow(id) { return document.getElementById(id); } /** * Returns chart action buttons from a chart container element. * @param {HTMLElement} chartEl - Chart container (e.g. cardContainer.firstElementChild) - * @returns {{ rebroadcast: Element|null, deleteKps: Element|null, unifyRelays: Element|null, operatingRoom: Element|null }} + * @returns {{ rebroadcast: Element|null, deleteKps: Element|null, deleteOrphanedKps: Element|null, unifyRelays: Element|null, deleteKind4: Element|null, operatingRoom: Element|null }} */ export function getChartActionButtons(chartEl) { if (!chartEl) return { @@ -42,3 +42,15 @@ export function getChartActionButtons(chartEl) { operatingRoom: chartEl.querySelector('[data-action="enter-operating-room"]'), }; } + +/** Returns an OR tray element by kind number within a container. */ +export function getTrayByKind(container, kind) { + if (!container) return null; + return container.querySelector(`.or-tray[data-kind="${kind}"]`); +} + +/** Returns an OR add-relay button by kind within a container. */ +export function getAddBtnByKind(container, kind) { + if (!container) return null; + return container.querySelector(`.or-add-btn[data-kind="${kind}"]`); +} diff --git a/js/nak-commands.js b/js/nak-commands.js index 28f0c8d..8182dd4 100644 --- a/js/nak-commands.js +++ b/js/nak-commands.js @@ -22,8 +22,8 @@ export function getNakCommand(action, state) { const { pubkey, relaysToInvestigate } = state; if (!pubkey) return null; - const relayArgs = (relaysToInvestigate || []).join(' '); - const sourceRelay = relaysToInvestigate?.[0] || 'wss://relay.damus.io'; + const relayArgs = (relaysToInvestigate || []).map(shellQuote).join(' '); + const sourceRelay = shellQuote(relaysToInvestigate?.[0] || 'wss://relay.damus.io'); switch (action) { case 'rebroadcast': @@ -73,7 +73,7 @@ function buildDeleteOrphanedKpsCmd(state, relayArgs) { const { orphanedKpRelays } = state; if (!orphanedKpRelays || orphanedKpRelays.length === 0) return null; - const orphanRelayArgs = orphanedKpRelays.join(' '); + const orphanRelayArgs = orphanedKpRelays.map(shellQuote).join(' '); const pk = state.pubkey; const cmd = '# Step 1: Find KeyPackage IDs on orphaned relays\n' + 'nak req -k 443 -a ' + pk + ' ' + orphanRelayArgs + '\n\n' @@ -97,11 +97,11 @@ function buildUnifyCmd(state, relayArgs) { const merged = [...new Set([...(k3RelaySet || []), ...(k10002RelaySet || [])])]; if (merged.length === 0) return null; - // Build k10002 r-tags - const rTags = merged.map(r => '--tag r=' + r).join(' \\\n '); + // Build k10002 r-tags (quote the tag value to prevent shell injection) + const rTags = merged.map(r => '--tag ' + shellQuote('r=' + r)).join(' \\\n '); // Build k3 relay tags + preserve p-tags - const relayTags = merged.map(r => '--tag relay=' + r).join(' \\\n '); + const relayTags = merged.map(r => '--tag ' + shellQuote('relay=' + r)).join(' \\\n '); const pTags = (bestK3?.tags || []) .filter(t => Array.isArray(t) && t[0] === 'p' && t[1]) .map(t => '-p ' + t[1]) @@ -142,6 +142,16 @@ function buildDeleteKind4Cmd(pubkey, relayArgs) { }; } +/** + * Wrap a string in single quotes for safe shell usage. + * Escapes embedded single quotes via the '\'' idiom. + * @param {string} str + * @returns {string} + */ +function shellQuote(str) { + return "'" + String(str).replace(/'/g, "'\\''") + "'"; +} + function escapeShellContent(str) { // Escape single quotes for shell: replace ' with '\'' return str.replace(/'/g, "'\\''"); diff --git a/js/operate/editor.js b/js/operate/editor.js index f9c4432..453f3f9 100644 --- a/js/operate/editor.js +++ b/js/operate/editor.js @@ -238,7 +238,17 @@ export function getFinalRelayList(kind) { * Clear all staged changes without applying them. */ export function clearAllChanges() { - for (const kind of stagedChanges.keys()) { + // Restore readWrite values from modify changes before clearing + for (const [kind, changes] of stagedChanges.entries()) { + const relays = currentRelays.get(kind) || []; + for (const change of changes) { + if (change.type === 'modify' && change.prevReadWrite) { + const entry = relays.find( + r => r.url.toLowerCase() === change.url.toLowerCase() && !r.staged, + ); + if (entry) entry.readWrite = change.prevReadWrite; + } + } stagedChanges.set(kind, []); } // Re-init from currentRelays by removing staged entries and un-removing removed entries diff --git a/js/operate/guide.js b/js/operate/guide.js index 4c08f89..e05ab17 100644 --- a/js/operate/guide.js +++ b/js/operate/guide.js @@ -76,7 +76,7 @@ export function initGuide(prescriptions, _findings, auditState) { fixAction: getFixActionForPrescription(rx), })); currentStep = steps.length > 0 ? 0 : -1; - isGuided = true; + isGuided = steps.length > 0; return steps.length; } @@ -88,6 +88,7 @@ export function nextStep() { if (!isGuided) return null; currentStep++; if (currentStep >= steps.length) { + isGuided = false; return null; } return steps[currentStep]; diff --git a/js/operate/health.js b/js/operate/health.js index 19013e9..9dbeada 100644 --- a/js/operate/health.js +++ b/js/operate/health.js @@ -28,11 +28,12 @@ function testWsConnect(relayUrl) { return new Promise((resolve) => { const start = performance.now(); let resolved = false; + let timer = null; const done = (result) => { if (resolved) return; resolved = true; - clearTimeout(timer); + if (timer != null) clearTimeout(timer); resolve(result); }; @@ -44,7 +45,7 @@ function testWsConnect(relayUrl) { return; } - const timer = setTimeout(() => { + timer = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } done({ connectMs: null, error: 'timeout', ws: null }); }, WS_CONNECT_TIMEOUT_MS); diff --git a/js/operate/nip11.js b/js/operate/nip11.js index fe4160e..b4a863f 100644 --- a/js/operate/nip11.js +++ b/js/operate/nip11.js @@ -3,6 +3,8 @@ * Converts wss:// relay URLs to https:// and fetches the info doc. */ +import { validateRelayUrl } from '../relay-validation.js'; + const NIP11_TIMEOUT_MS = 5000; /** @@ -40,16 +42,17 @@ function wsToHttp(wsUrl) { * }>} */ export async function fetchNip11(relayUrl) { + const { valid } = validateRelayUrl(relayUrl); + if (!valid) return { error: 'invalid relay URL' }; + const httpUrl = wsToHttp(relayUrl); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), NIP11_TIMEOUT_MS); try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), NIP11_TIMEOUT_MS); - const resp = await fetch(httpUrl, { headers: { 'Accept': 'application/nostr+json' }, signal: controller.signal, }); - clearTimeout(timer); if (!resp.ok) { return { error: `HTTP ${resp.status}` }; @@ -67,6 +70,8 @@ export async function fetchNip11(relayUrl) { return { error: 'timeout' }; } return { error: e?.message || 'fetch failed' }; + } finally { + clearTimeout(timer); } } diff --git a/js/operate/procedures.js b/js/operate/procedures.js index 490552c..227ca73 100644 --- a/js/operate/procedures.js +++ b/js/operate/procedures.js @@ -34,7 +34,6 @@ const PUBLISH_TIMEOUT_MS = 15_000; */ function buildK10002Event(pubkey) { const relays = getFinalRelayList(10002); - if (relays.length === 0) return null; const tags = relays.map(({ url, readWrite }) => { if (readWrite === 'read') return ['r', url, 'read']; @@ -58,7 +57,6 @@ function buildK10002Event(pubkey) { */ function buildK10050Event(pubkey) { const relays = getFinalRelayList(10050); - if (relays.length === 0) return null; return { kind: 10050, @@ -76,7 +74,6 @@ function buildK10050Event(pubkey) { */ function buildK10051Event(pubkey) { const relays = getFinalRelayList(10051); - if (relays.length === 0) return null; return { kind: 10051, @@ -97,7 +94,7 @@ function buildK10051Event(pubkey) { function buildK3Event(pubkey) { const state = getAuditState(); const relays = getFinalRelayList(3); - if (relays.length === 0 && !state?.bestK3) return null; + if (!state?.bestK3 && relays.length === 0) return null; // No existing k3 and nothing to publish // Preserve all non-relay tags (p tags, etc.) from existing k3 const preservedTags = []; diff --git a/js/operate/room.js b/js/operate/room.js index c89b5d5..c3789b2 100644 --- a/js/operate/room.js +++ b/js/operate/room.js @@ -5,7 +5,7 @@ */ import { getAuditState } from '../audit.js'; -import { relayList } from '../dom.js'; +import { relayList, getTrayByKind, getAddBtnByKind } from '../dom.js'; import { say } from '../dialog.js'; import { speak } from '../personalities.js'; import { setSprite } from '../sprite.js'; @@ -45,6 +45,7 @@ import { let isOperating = false; let healthResults = null; +let orSessionId = 0; /** @type {HTMLElement[]} Saved chart DOM nodes (preserved with event listeners) */ let savedChartNodes = []; @@ -78,6 +79,7 @@ export async function enterOperatingRoom() { } isOperating = true; + orSessionId++; healthResults = new Map(); // Save current relay list DOM nodes (preserves event listeners) [S1 fix] @@ -134,6 +136,7 @@ export async function enterOperatingRoom() { */ export function exitOperatingRoom() { isOperating = false; + orSessionId++; healthResults = null; clearAllChanges(); exitGuide(); @@ -175,7 +178,7 @@ function renderRoom() { if (isGuidedMode()) { const step = getCurrentStep(); if (step?.targetKind) { - const tray = relayList.querySelector(`.or-tray[data-kind="${step.targetKind}"]`); + const tray = getTrayByKind(relayList, step.targetKind); if (tray) { tray.classList.add('or-tray-highlighted'); tray.classList.remove('or-tray-collapsed'); @@ -189,6 +192,8 @@ function renderRoom() { * @param {Object} auditState - Audit state */ async function runHealthChecks(auditState) { + const session = orSessionId; + // Collect all unique relay URLs from all kinds const allUrls = new Set(); for (const kind of [10002, 10050, 10051, 3]) { @@ -205,26 +210,32 @@ async function runHealthChecks(auditState) { scanBeep(); await say(speak('orHealthStart') || 'Running relay diagnostics...'); + if (session !== orSessionId) return; // OR closed during say() + addScanBar(); setScanProgress(10); let checked = 0; const total = allUrls.size; - healthResults = await checkAllRelaysHealth( + const results = await checkAllRelaysHealth( [...allUrls], (_url, phase) => { - // onRelayProgress β€” update status in real-time if visible + if (session !== orSessionId) return; if (phase === 'connecting') scanBeep(); }, (_result) => { - // onRelayDone + if (session !== orSessionId) return; checked++; const pct = Math.round(10 + (80 * checked / total)); setScanProgress(pct); }, ); + // Bail if OR was closed during health checks + if (session !== orSessionId) return; + + healthResults = results; setScanProgress(100); removeScanBar(); @@ -241,6 +252,8 @@ async function runHealthChecks(auditState) { await say(speak('orHealthGood', { count: healthy }) || `All ${healthy} relay(s) responding. Vitals stable.`); } + if (session !== orSessionId) return; + // Re-render with health data renderRoom(); } @@ -489,7 +502,7 @@ async function executeFixAction(fixAction) { break; } default: - console.error('Unknown fix action:', fixAction); + throw new Error('Unknown fix action: ' + fixAction); } } @@ -500,7 +513,7 @@ async function executeFixAction(fixAction) { function handleAddInputKeydown(e) { if (e.key !== 'Enter') return; const kind = Number(e.target.dataset.kind); - const addBtn = relayList.querySelector(`.or-add-btn[data-kind="${kind}"]`); + const addBtn = getAddBtnByKind(relayList, kind); if (addBtn) addBtn.click(); } @@ -540,8 +553,8 @@ async function executeOperations() { // Determine publish targets: all relays we know about const publishRelays = [...new Set([ - ...auditState.relaysToInvestigate, - ...auditState.marmotRelays, + ...(Array.isArray(auditState.relaysToInvestigate) ? auditState.relaysToInvestigate : []), + ...(Array.isArray(auditState.marmotRelays) ? auditState.marmotRelays : []), ])]; const results = await executeAllProcedures( diff --git a/style.css b/style.css index e0960a6..2484c2f 100644 --- a/style.css +++ b/style.css @@ -24,6 +24,8 @@ --c-cyan: #38e8e8; --c-dim: #484878; --c-shadow: #000044; + --c-text: #f0f0ff; + --c-panel: #08082a; --c-relay-border: #050510; --topbar-h: 5.5ch; @@ -1147,11 +1149,6 @@ body { position: relative; } -#nip07-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - #nip07-btn:not(:disabled):hover { background: linear-gradient(180deg, #3a5a3a, #1a3a1a); color: var(--c-yellow); @@ -1195,6 +1192,7 @@ body { } #nip07-btn:disabled { + opacity: 0.4; background: #111130; color: var(--c-dim); border-color: var(--c-dim); From e1a35cf6692a16e6a79287fa18780893bd786d54 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 4 Mar 2026 13:29:36 -0300 Subject: [PATCH 4/6] small carrots --- js/audit/chartRender.js | 4 ++-- js/nak-commands.js | 38 ++++++++++++++++++++++++++++---------- js/operate/guide.js | 3 ++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/js/audit/chartRender.js b/js/audit/chartRender.js index 46e7e61..762f41b 100644 --- a/js/audit/chartRender.js +++ b/js/audit/chartRender.js @@ -61,7 +61,7 @@ function buildDoctorNotesSection(doctorNote) {
-
${doctorNote}
+
${escapeHtml(doctorNote)}
`; @@ -184,7 +184,7 @@ function buildTreatmentSection(prescriptions, actionFlags, canOperate, auditStat return html`
- ${rxText} + ${escapeHtml(rxText)} ${actionButton}
${nakBlock} diff --git a/js/nak-commands.js b/js/nak-commands.js index 8182dd4..46d24fd 100644 --- a/js/nak-commands.js +++ b/js/nak-commands.js @@ -9,6 +9,17 @@ import { escapeHtml } from './html.js'; +const HEX_RE = /^[0-9a-f]{64}$/i; + +/** + * Validate that a string is a 64-char hex ID (event ID or pubkey). + * @param {string} val + * @returns {boolean} + */ +function isValidHex64(val) { + return typeof val === 'string' && HEX_RE.test(val); +} + /** * Generate a nak command string for a given prescription action. * @@ -42,9 +53,10 @@ export function getNakCommand(action, state) { } function buildRebroadcastCmd(pubkey, sourceRelay, relayArgs) { - const cmd = `# Rebroadcast profile (k0) and contacts (k3) to all relays\n` - + `nak req -k 0 -k 3 -a ${pubkey} ${sourceRelay} | \\\n` - + ` nak event ${relayArgs}`; + if (!isValidHex64(pubkey)) return null; + const cmd = '# Rebroadcast profile (k0) and contacts (k3) to all relays\n' + + 'nak req -k 0 -k 3 -a ' + shellQuote(pubkey) + ' ' + sourceRelay + ' | \\\n' + + ' nak event ' + relayArgs; return { cmd, @@ -56,7 +68,10 @@ function buildDeleteKpsCmd(state, relayArgs) { const { missingITagIds } = state; if (!missingITagIds || missingITagIds.length === 0) return null; - const eTags = missingITagIds.map(id => '-e ' + id).join(' \\\n '); + const validIds = missingITagIds.filter(isValidHex64); + if (validIds.length === 0) return null; + + const eTags = validIds.map(id => '-e ' + shellQuote(id)).join(' \\\n '); const cmd = '# Delete orphaned/invalid KeyPackages (kind 5 deletion)\n' + 'nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n' + ' ' + eTags + ' \\\n' @@ -65,7 +80,7 @@ function buildDeleteKpsCmd(state, relayArgs) { return { cmd, - description: 'Signs kind 5 deletion events for ' + missingITagIds.length + ' KeyPackage(s) and publishes to all relays. Set NOSTR_SECRET_KEY first.', + description: 'Signs kind 5 deletion events for ' + validIds.length + ' KeyPackage(s) and publishes to all relays. Set NOSTR_SECRET_KEY first.', }; } @@ -75,8 +90,9 @@ function buildDeleteOrphanedKpsCmd(state, relayArgs) { const orphanRelayArgs = orphanedKpRelays.map(shellQuote).join(' '); const pk = state.pubkey; + if (!isValidHex64(pk)) return null; const cmd = '# Step 1: Find KeyPackage IDs on orphaned relays\n' - + 'nak req -k 443 -a ' + pk + ' ' + orphanRelayArgs + '\n\n' + + 'nak req -k 443 -a ' + shellQuote(pk) + ' ' + orphanRelayArgs + '\n\n' + '# Step 2: Delete them (replace with IDs from step 1)\n' + 'nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n' + ' -e \\\n' @@ -103,8 +119,8 @@ function buildUnifyCmd(state, relayArgs) { // Build k3 relay tags + preserve p-tags const relayTags = merged.map(r => '--tag ' + shellQuote('relay=' + r)).join(' \\\n '); const pTags = (bestK3?.tags || []) - .filter(t => Array.isArray(t) && t[0] === 'p' && t[1]) - .map(t => '-p ' + t[1]) + .filter(t => Array.isArray(t) && t[0] === 'p' && t[1] && isValidHex64(t[1])) + .map(t => '-p ' + shellQuote(t[1])) .join(' \\\n '); const cmd = '# Publish unified kind 10002 (NIP-65 relay list)\n' @@ -126,10 +142,12 @@ function buildUnifyCmd(state, relayArgs) { } function buildDeleteKind4Cmd(pubkey, relayArgs) { + if (!isValidHex64(pubkey)) return null; + const qpk = shellQuote(pubkey); const cmd = '# Step 1: Find all kind 4 (NIP-04 DM) event IDs\n' - + 'nak req -k 4 -a ' + pubkey + ' ' + relayArgs + '\n\n' + + 'nak req -k 4 -a ' + qpk + ' ' + relayArgs + '\n\n' + '# Step 2: Delete them (pipe IDs or list them manually)\n' - + 'nak req -k 4 -a ' + pubkey + ' ' + relayArgs + ' | \\\n' + + 'nak req -k 4 -a ' + qpk + ' ' + relayArgs + ' | \\\n' + " jq -r '.id' | \\\n" + ' xargs -I {} nak event -k 5 --sec $NOSTR_SECRET_KEY \\\n' + ' -e {} \\\n' diff --git a/js/operate/guide.js b/js/operate/guide.js index e05ab17..edb626e 100644 --- a/js/operate/guide.js +++ b/js/operate/guide.js @@ -288,6 +288,8 @@ function isClientSidePrescription(rx) { const lower = rx.toLowerCase(); // NIP-04 migration is now actionable via deleteDeprecatedKind4 if (lower.includes('migrate from nip-04')) return false; + // 'delete events' removed β€” prescriptions matching that text are handled + // by the 'delete-kps' fix action and should not be classified as CLIENT-only. const clientPatterns = [ 'encoding', '0xf2ee', @@ -300,7 +302,6 @@ function isClientSidePrescription(rx) { 'default extensions', 'stop publishing kind 2', 'i tag', - 'delete events', ]; return clientPatterns.some(p => lower.includes(p)); } From 9b47010c22f0d64fb1cb04154f7c27b5822f5081 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 5 Mar 2026 08:01:51 -0300 Subject: [PATCH 5/6] carrot --- js/operate/guide.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/operate/guide.js b/js/operate/guide.js index edb626e..db89c79 100644 --- a/js/operate/guide.js +++ b/js/operate/guide.js @@ -152,13 +152,13 @@ export function renderGuideCard(step) { const doctorNote = speak('guideStepNote', { step: step.index + 1, total: steps.length }) || ''; - const requiresNip07 = !window.nostr; + const isNip07Required = !window.nostr; const fixBtn = step.fixAction ? html` ` : ''; @@ -240,8 +240,8 @@ function getTargetKindForPrescription(rx) { const lower = rx.toLowerCase(); // Check for multi-kind prescriptions first - const multiKind = ['k3 / k10002', 'k3/k10002', 'unify'].some(p => lower.includes(p)); - if (multiKind) return 10002; // Primary target is k10002 + const isMultiKind = ['k3 / k10002', 'k3/k10002', 'unify'].some(p => lower.includes(p)); + if (isMultiKind) return 10002; // Primary target is k10002 for (const [kind, patterns] of Object.entries(KIND_PATTERNS)) { if (patterns.some(p => lower.includes(p.toLowerCase()))) { From 7cbe45325ab973383d628794c173a7bdcc38c5da Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Thu, 5 Mar 2026 09:42:19 -0300 Subject: [PATCH 6/6] jeff mode needs the operating room too --- jeff.css | 1520 +++++++++++++++++++++++++++++++++++++++---- js/personalities.js | 27 + style.css | 146 ++++- 3 files changed, 1551 insertions(+), 142 deletions(-) diff --git a/jeff.css b/jeff.css index 0cd488f..58127f6 100644 --- a/jeff.css +++ b/jeff.css @@ -212,6 +212,9 @@ body[data-mode="jeff"] .result-section, body[data-mode="jeff"] .patient-chart { flex: 0 0 300px; min-width: 280px; + min-height: 0; + height: 100%; + max-height: 100%; background: var(--c-panel-bg); border: 0.5px solid var(--c-border); border-radius: 8px; @@ -299,15 +302,41 @@ body[data-mode="jeff"] .relay-row.error .relay-dot { box-shadow: none; } -body[data-mode="jeff"] .relay-row.connecting .relay-dot { background: var(--c-yellow); } -body[data-mode="jeff"] .relay-row.ok .relay-dot { background: var(--c-green); } -body[data-mode="jeff"] .relay-row.warn .relay-dot { background: var(--c-yellow); } -body[data-mode="jeff"] .relay-row.error .relay-dot { background: var(--c-red); } +body[data-mode="jeff"] .relay-row.connecting .relay-dot { + background: var(--c-yellow); +} + +body[data-mode="jeff"] .relay-row.ok .relay-dot { + background: var(--c-green); +} + +body[data-mode="jeff"] .relay-row.warn .relay-dot { + background: var(--c-yellow); +} + +body[data-mode="jeff"] .relay-row.error .relay-dot { + background: var(--c-red); +} + +body[data-mode="jeff"] .relay-row.connecting { + border-color: var(--c-border); + background: rgba(191, 134, 0, 0.03); +} + +body[data-mode="jeff"] .relay-row.ok { + border-color: var(--c-border); + background: rgba(36, 138, 61, 0.03); +} + +body[data-mode="jeff"] .relay-row.warn { + border-color: var(--c-border); + background: rgba(191, 134, 0, 0.03); +} -body[data-mode="jeff"] .relay-row.connecting { border-color: var(--c-border); background: rgba(191, 134, 0, 0.03); } -body[data-mode="jeff"] .relay-row.ok { border-color: var(--c-border); background: rgba(36, 138, 61, 0.03); } -body[data-mode="jeff"] .relay-row.warn { border-color: var(--c-border); background: rgba(191, 134, 0, 0.03); } -body[data-mode="jeff"] .relay-row.error { border-color: var(--c-border); background: rgba(215, 0, 21, 0.03); } +body[data-mode="jeff"] .relay-row.error { + border-color: var(--c-border); + background: rgba(215, 0, 21, 0.03); +} body[data-mode="jeff"] .relay-row.connecting .relay-dot { animation: none; @@ -321,10 +350,10 @@ body[data-mode="jeff"] .relay-row.connecting .relay-dot { body[data-mode="jeff"] .result-section { border-top: none; margin-top: 0; - padding-top: 0; + padding-top: 8px; display: flex; flex-direction: column; - gap: 2px; + gap: 4px; } body[data-mode="jeff"] .result-section-title { @@ -340,15 +369,22 @@ body[data-mode="jeff"] .result-section-title { } body[data-mode="jeff"] .result-row { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + align-items: start; + column-gap: 8px; font-family: var(--jeff-font); font-size: 11px; - line-height: 1.45; - padding: 2px 0; + line-height: 1.5; + padding: 3px 0; + overflow-wrap: anywhere; } body[data-mode="jeff"] .result-icon { font-size: 10px; width: 14px; + text-align: center; + line-height: 1.2; } body[data-mode="jeff"] .pop-in { @@ -562,6 +598,8 @@ body[data-mode="jeff"] #nip07-btn:disabled { body[data-mode="jeff"] .patient-chart { animation: none; margin-top: 0; + display: flex; + flex-direction: column; overflow: hidden; } @@ -603,8 +641,12 @@ body[data-mode="jeff"] .chart-date { } body[data-mode="jeff"] .chart-body { + flex: 1 1 auto; + min-height: 0; padding: 10px; gap: 10px; + overflow-x: hidden; + overflow-y: auto; } body[data-mode="jeff"] .chart-section { @@ -651,9 +693,20 @@ body[data-mode="jeff"] .chart-hp-fill { border-radius: 2px; } -body[data-mode="jeff"] .chart-hp-fill.hp-full { background: var(--c-green); box-shadow: none; } -body[data-mode="jeff"] .chart-hp-fill.hp-warn { background: var(--c-yellow); box-shadow: none; } -body[data-mode="jeff"] .chart-hp-fill.hp-crit { background: var(--c-red); box-shadow: none; } +body[data-mode="jeff"] .chart-hp-fill.hp-full { + background: var(--c-green); + box-shadow: none; +} + +body[data-mode="jeff"] .chart-hp-fill.hp-warn { + background: var(--c-yellow); + box-shadow: none; +} + +body[data-mode="jeff"] .chart-hp-fill.hp-crit { + background: var(--c-red); + box-shadow: none; +} body[data-mode="jeff"] .chart-hp-fill::after { display: none; @@ -696,11 +749,18 @@ body[data-mode="jeff"] .dx-icon { } body[data-mode="jeff"] .rx-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; + column-gap: 0; + row-gap: 4px; font-family: var(--jeff-font); font-size: 11px; line-height: 1.4; color: var(--c-white); padding: 2px 0 2px 16px; + max-height: 180px; + overflow-y: auto; } body[data-mode="jeff"] .rx-row::before { @@ -710,6 +770,20 @@ body[data-mode="jeff"] .rx-row::before { body[data-mode="jeff"] .rx-text { font-size: 11px; + min-width: 0; + overflow-wrap: anywhere; +} + +body[data-mode="jeff"] .chart-treatment { + display: flex; + flex-direction: column; + gap: 6px; +} + +body[data-mode="jeff"] .rx-row-wrap { + display: flex; + flex-direction: column; + gap: 2px; } body[data-mode="jeff"] .rx-action-btn { @@ -723,6 +797,9 @@ body[data-mode="jeff"] .rx-action-btn { color: var(--c-green); cursor: pointer; letter-spacing: 0; + justify-self: start; + align-self: start; + margin-top: 2px; } body[data-mode="jeff"] .rx-action-btn::before { @@ -805,123 +882,1344 @@ body[data-mode="jeff"] #jeff-footer a:hover { /* ══════════════════════════════════════════ - MOBILE β€” Jeff Mode + OPERATING ROOM β€” Clean relay management ══════════════════════════════════════════ */ -@media (max-width: 768px) { - body[data-mode="jeff"] { - font-size: 13px; - } - body[data-mode="jeff"] #screen-viewport { - padding: 12px 8px; - } +/* ── Editor Container ── */ +body[data-mode="jeff"] .or-editor { + gap: 10px; + animation: none; + max-height: 100%; + overflow-y: scroll; +} - body[data-mode="jeff"] #game-wrap { - border-radius: 0; - box-shadow: none; - } +/* ── Header ── */ +body[data-mode="jeff"] .or-editor-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--c-panel-bg); + border: 0.5px solid var(--c-border); + border-radius: 8px; + box-shadow: 0 1px 2px var(--c-shadow); +} - body[data-mode="jeff"] #top-bar { - border-radius: 8px; - padding: 10px 16px; - } +body[data-mode="jeff"] .or-editor-header::before, +body[data-mode="jeff"] .or-editor-header::after { + display: none; +} - body[data-mode="jeff"] .title-pixel { - font-size: 14px; - } +body[data-mode="jeff"] .or-editor-title { + font-family: var(--jeff-font-display); + font-size: 13px; + font-weight: 600; + color: var(--c-white); + letter-spacing: 0; + text-shadow: none; +} - body[data-mode="jeff"] .subtitle-pixel { - display: none; - } +body[data-mode="jeff"] .or-editor-subtitle { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 400; + color: var(--c-dim); + letter-spacing: 0; + opacity: 1; +} - body[data-mode="jeff"] #sprite-panel { - display: none; - } +/* ── Relay Trays ── */ +body[data-mode="jeff"] .or-tray { + border: 0.5px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel-bg); + box-shadow: 0 1px 2px var(--c-shadow); + animation: none; + transition: border-color 0.15s; +} - body[data-mode="jeff"] #status-panel-title { - padding: 8px 16px 4px; - font-size: 10px; - } +body[data-mode="jeff"] .or-tray:hover { + border-color: var(--c-border); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} - body[data-mode="jeff"] #relay-list { - padding: 10px 12px 12px; - gap: 8px; - height: 45vh; - max-height: 360px; - } +body[data-mode="jeff"] .or-tray-highlighted { + border-color: var(--c-cyan) !important; + box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.12) !important; +} - body[data-mode="jeff"] .relay-group, - body[data-mode="jeff"] .result-section, - body[data-mode="jeff"] .patient-chart { - flex: 0 0 240px; - min-width: 220px; - } +body[data-mode="jeff"] .or-tray-highlighted>.or-tray-header { + background: rgba(0, 113, 227, 0.04); +} - body[data-mode="jeff"] .patient-chart { - flex-basis: 280px; - min-width: 260px; - } +body[data-mode="jeff"] .or-tray-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + background: transparent; + border-bottom: 0.5px solid var(--c-border); + user-select: none; + transition: background 0.15s; + position: relative; +} - body[data-mode="jeff"] #dialog-box { - padding: 10px 16px; - } +body[data-mode="jeff"] .or-tray-header::before { + display: none; +} - body[data-mode="jeff"] #dialog-text { - font-size: 12px; - } +body[data-mode="jeff"] .or-tray-header:hover { + background: var(--c-bg); +} - body[data-mode="jeff"] #input-row { - flex-direction: column; - align-items: stretch; - padding: 10px 16px; - gap: 0; - } +body[data-mode="jeff"] .or-tray-collapsed .or-tray-header { + border-bottom: none; +} - body[data-mode="jeff"] .input-inner { - flex-direction: column; - } +body[data-mode="jeff"] .or-tray-icon { + font-size: 14px; + line-height: 1; + filter: none; +} - body[data-mode="jeff"] #npub-input { - font-size: 16px; - padding: 10px 12px; - border-radius: 6px 6px 0 0; - border-right: 0.5px solid var(--c-border); - border-bottom: none; - } +body[data-mode="jeff"] .or-tray-title { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 500; + color: var(--c-white); + letter-spacing: 0; + text-shadow: none; + flex: 1; +} - body[data-mode="jeff"] #audit-btn { - font-size: 13px; - padding: 10px 16px; - border-radius: 0 0 6px 6px; - width: 100%; - } +body[data-mode="jeff"] .or-tray-tag { + font-family: var(--jeff-font-mono); + font-size: 9px; + color: var(--c-dim); + padding: 1px 5px; + border: 0.5px solid var(--c-border); + border-radius: 3px; + letter-spacing: 0; + background: var(--c-bg); +} - body[data-mode="jeff"] #signer-box { - border-radius: 0; - padding: 8px 16px; - } +body[data-mode="jeff"] .or-tray-count { + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-dim); + text-shadow: none; +} - body[data-mode="jeff"] .chart-header { - flex-direction: column; - gap: 2px; - padding: 6px 8px; - } +body[data-mode="jeff"] .or-tray-arrow { + font-size: 10px; + color: var(--c-dim); + text-shadow: none; + transition: transform 0.2s; +} - body[data-mode="jeff"] .chart-header-right { - text-align: left; - } +body[data-mode="jeff"] .or-tray-body { + padding: 6px 10px; + display: flex; + flex-direction: column; + gap: 3px; + background: transparent; +} - body[data-mode="jeff"] .chart-body { - padding: 8px; - gap: 8px; - } +/* ── Staged Badge ── */ +body[data-mode="jeff"] .or-staged-badge { + font-family: var(--jeff-font); + font-size: 9px; + font-weight: 500; + color: var(--c-cyan); + background: rgba(0, 113, 227, 0.08); + border: 0.5px solid var(--c-cyan); + border-radius: 3px; + padding: 1px 5px; + letter-spacing: 0; + text-shadow: none; + animation: none; +} - body[data-mode="jeff"] .rx-row { - flex-wrap: wrap; - gap: 4px; - } +/* ── Relay Rows ── */ +body[data-mode="jeff"] .or-relay-row { + display: grid; + grid-template-columns: 14px 8px 1fr auto auto auto auto; + align-items: center; + gap: 6px; + padding: 5px 8px; + font-family: var(--jeff-font); + font-size: 11px; + border: 0.5px solid var(--c-border); + border-radius: 5px; + background: var(--c-panel-bg); + transition: background 0.15s; + position: relative; +} - body[data-mode="jeff"] #jeff-footer { - padding: 12px 16px 20px; - } +body[data-mode="jeff"] .or-relay-row:nth-child(even) { + background: var(--c-bg); +} + +body[data-mode="jeff"] .or-relay-row:hover { + background: var(--c-bg); + border-color: var(--c-border); +} + +/* Change icon */ +body[data-mode="jeff"] .or-change-icon { + font-size: 12px; + font-weight: 600; + width: 14px; + text-align: center; } + +body[data-mode="jeff"] .or-change-add { + color: var(--c-green); + text-shadow: none; +} + +body[data-mode="jeff"] .or-change-remove { + color: var(--c-red); + text-shadow: none; +} + +/* Status dot */ +body[data-mode="jeff"] .or-relay-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--c-dim); + border: none; + box-shadow: none; +} + +body[data-mode="jeff"] .or-health-ok .or-relay-dot { + background: var(--c-green); + box-shadow: none; +} + +body[data-mode="jeff"] .or-health-warn .or-relay-dot { + background: var(--c-yellow); + box-shadow: none; + animation: none; +} + +body[data-mode="jeff"] .or-health-dead .or-relay-dot { + background: var(--c-red); + box-shadow: none; +} + +/* URL */ +body[data-mode="jeff"] .or-relay-url { + font-family: var(--jeff-font); + font-size: 11px; + color: var(--c-white); +} + +body[data-mode="jeff"] .or-staged .or-relay-url { + color: var(--c-green); + text-shadow: none; +} + +body[data-mode="jeff"] .or-removed .or-relay-url { + color: var(--c-red); + text-decoration: line-through; + opacity: 0.5; +} + +body[data-mode="jeff"] .or-staged { + border-color: var(--c-green); + border-style: dashed; + background: rgba(36, 138, 61, 0.04); + animation: none; +} + +body[data-mode="jeff"] .or-removed { + border-color: var(--c-red); + border-style: dashed; + background: rgba(215, 0, 21, 0.04); +} + +/* Read/Write toggles */ +body[data-mode="jeff"] .or-rw-toggles { + display: flex; + gap: 2px; +} + +body[data-mode="jeff"] .or-rw-btn { + font-family: var(--jeff-font); + font-size: 9px; + font-weight: 500; + padding: 2px 5px; + background: var(--c-bg); + color: var(--c-dim); + border: 0.5px solid var(--c-border); + border-radius: 3px; + cursor: pointer; + transition: all 0.15s; + line-height: 1; + position: relative; +} + +body[data-mode="jeff"] .or-rw-btn.or-rw-active { + color: var(--c-cyan); + border-color: var(--c-cyan); + background: rgba(0, 113, 227, 0.06); + text-shadow: none; + box-shadow: none; +} + +body[data-mode="jeff"] .or-rw-btn.or-rw-active::before { + display: none; +} + +body[data-mode="jeff"] .or-rw-btn:hover { + border-color: var(--c-cyan); + color: var(--c-cyan); + text-shadow: none; +} + +/* Latency */ +body[data-mode="jeff"] .or-relay-latency { + font-family: var(--jeff-font-mono); + font-size: 9px; + color: var(--c-dim); +} + +body[data-mode="jeff"] .or-health-ok .or-relay-latency { + color: var(--c-green); +} + +body[data-mode="jeff"] .or-health-warn .or-relay-latency { + color: var(--c-yellow); +} + +body[data-mode="jeff"] .or-health-dead .or-relay-latency { + color: var(--c-red); +} + +/* Mini HP bar */ +body[data-mode="jeff"] .or-relay-hp { + display: flex; + align-items: center; + min-width: 40px; +} + +body[data-mode="jeff"] .or-hp-track { + width: 100%; + height: 4px; + background: var(--c-bg); + border: 0.5px solid var(--c-border); + border-radius: 2px; + position: relative; + overflow: hidden; + box-shadow: none; +} + +body[data-mode="jeff"] .or-hp-fill { + height: 100%; + border-radius: 1.5px; + background: var(--c-green); + box-shadow: none; + transition: width 0.3s ease; +} + +body[data-mode="jeff"] .or-hp-fill::after { + display: none; +} + +body[data-mode="jeff"] .or-health-warn .or-hp-fill { + background: var(--c-yellow); + box-shadow: none; +} + +body[data-mode="jeff"] .or-health-dead .or-hp-fill { + background: var(--c-red); + box-shadow: none; +} + +/* Action buttons (remove, undo) */ +body[data-mode="jeff"] .or-relay-action { + font-family: var(--jeff-font); + font-size: 10px; + padding: 2px 6px; + background: var(--c-panel-bg); + border: 0.5px solid var(--c-border); + border-radius: 4px; + color: var(--c-dim); + cursor: pointer; + transition: all 0.15s; + line-height: 1; + position: static; +} + +body[data-mode="jeff"] .or-relay-action::before { + display: none; +} + +body[data-mode="jeff"] .or-remove-btn:hover { + color: var(--c-red); + border-color: var(--c-red); + background: rgba(215, 0, 21, 0.04); + box-shadow: none; +} + +body[data-mode="jeff"] .or-undo-btn { + color: var(--c-yellow); + border-color: var(--c-yellow); + background: var(--c-panel-bg); +} + +body[data-mode="jeff"] .or-undo-btn:hover { + background: rgba(191, 134, 0, 0.06); + box-shadow: none; +} + +/* ── Add Relay ── */ +body[data-mode="jeff"] .or-add-row { + display: flex; + gap: 4px; + margin-top: 6px; + padding-top: 6px; + border-top: 0.5px solid var(--c-border); +} + +body[data-mode="jeff"] .or-add-input { + flex: 1; + font-family: var(--jeff-font-mono); + font-size: 11px; + padding: 5px 8px; + background: var(--c-bg); + color: var(--c-white); + border: 0.5px solid var(--c-border); + border-radius: 5px; + box-shadow: none; + caret-color: var(--c-cyan); + outline: none; +} + +body[data-mode="jeff"] .or-add-input::placeholder { + color: var(--c-dim); +} + +body[data-mode="jeff"] .or-add-input:focus { + border-color: var(--c-cyan); + box-shadow: 0 0 0 2.5px rgba(0, 113, 227, 0.12); +} + +body[data-mode="jeff"] .or-add-btn { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 500; + padding: 5px 10px; + background: var(--c-panel-bg); + color: var(--c-green); + border: 0.5px solid var(--c-green); + border-radius: 5px; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + position: static; + overflow: visible; +} + +body[data-mode="jeff"] .or-add-btn::before { + display: none; +} + +body[data-mode="jeff"] .or-add-btn:hover { + background: rgba(36, 138, 61, 0.06); + color: var(--c-green); + border-color: var(--c-green); + box-shadow: none; +} + +body[data-mode="jeff"] .or-add-btn:active { + background: rgba(36, 138, 61, 0.12); + transform: none; +} + +body[data-mode="jeff"] .or-add-error { + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-red); + padding: 2px 8px; + text-shadow: none; +} + +body[data-mode="jeff"] .or-empty { + font-family: var(--jeff-font); + font-size: 11px; + color: var(--c-dim); + padding: 12px; + text-align: center; + border: 0.5px dashed var(--c-border); + border-radius: 5px; + background: var(--c-bg); +} + +/* ── Bottom Actions ── */ +body[data-mode="jeff"] .or-actions { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border: 0.5px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel-bg); + box-shadow: 0 1px 2px var(--c-shadow); + animation: none; +} + +body[data-mode="jeff"] .or-staged-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: rgba(0, 113, 227, 0.04); + border: 0.5px solid var(--c-cyan); + border-radius: 5px; + box-shadow: none; +} + +body[data-mode="jeff"] .or-staged-count { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 500; + color: var(--c-cyan); + letter-spacing: 0; + text-shadow: none; +} + +body[data-mode="jeff"] .or-discard-btn { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 500; + padding: 4px 10px; + background: var(--c-panel-bg); + color: var(--c-red); + border: 0.5px solid var(--c-red); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; + position: static; +} + +body[data-mode="jeff"] .or-discard-btn::before { + display: none; +} + +body[data-mode="jeff"] .or-discard-btn:hover { + background: rgba(215, 0, 21, 0.05); + box-shadow: none; +} + +/* OPERATE button */ +body[data-mode="jeff"] .or-operate-btn { + font-family: var(--jeff-font); + font-size: 13px; + font-weight: 600; + padding: 10px 16px; + background: var(--c-cyan); + color: #ffffff; + border: none; + border-radius: 6px; + cursor: pointer; + letter-spacing: 0; + white-space: nowrap; + transition: background 0.15s; + position: static; + overflow: visible; + text-align: center; + box-shadow: none; +} + +body[data-mode="jeff"] .or-operate-btn::before { + display: none; +} + +body[data-mode="jeff"] .or-operate-btn:hover { + background: #0077ed; + color: #ffffff; + border-color: transparent; + box-shadow: none; + transform: none; +} + +body[data-mode="jeff"] .or-operate-btn:active { + background: #006adb; + box-shadow: none; + transform: none; +} + +body[data-mode="jeff"] .or-operate-icon { + font-size: 14px; + filter: none; +} + +body[data-mode="jeff"] .or-no-changes { + font-family: var(--jeff-font); + font-size: 11px; + color: var(--c-dim); + text-align: center; + padding: 8px; + border: 0.5px dashed var(--c-border); + border-radius: 5px; +} + +/* BACK TO CHART button */ +body[data-mode="jeff"] .or-close-btn { + font-family: var(--jeff-font); + font-size: 12px; + font-weight: 500; + padding: 7px 14px; + background: var(--c-panel-bg); + color: var(--c-dim); + border: 0.5px solid var(--c-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + text-align: center; + position: static; + box-shadow: none; +} + +body[data-mode="jeff"] .or-close-btn::before { + display: none; +} + +body[data-mode="jeff"] .or-close-btn:hover { + background: var(--c-bg); + color: var(--c-white); + border-color: var(--c-border); + box-shadow: none; +} + +body[data-mode="jeff"] .or-close-btn:active { + background: var(--c-bg); + transform: none; + box-shadow: none; +} + +/* ── Diff Preview ── */ +body[data-mode="jeff"] .or-diff-preview { + border: 0.5px solid var(--c-border); + border-radius: 6px; + background: var(--c-bg); + padding: 10px; + box-shadow: none; +} + +body[data-mode="jeff"] .or-diff-section { + margin-bottom: 8px; +} + +body[data-mode="jeff"] .or-diff-title { + font-family: var(--jeff-font); + font-size: 10px; + font-weight: 500; + color: var(--c-dim); + text-transform: uppercase; + letter-spacing: 0; + margin-bottom: 4px; + text-shadow: none; + padding-bottom: 3px; + border-bottom: 0.5px solid var(--c-border); +} + +body[data-mode="jeff"] .or-diff-row { + font-family: var(--jeff-font-mono); + font-size: 11px; + padding: 2px 6px; + line-height: 1.5; +} + +body[data-mode="jeff"] .or-diff-add { + color: var(--c-green); + text-shadow: none; +} + +body[data-mode="jeff"] .or-diff-remove { + color: var(--c-red); + text-shadow: none; +} + +body[data-mode="jeff"] .or-diff-modify { + color: var(--c-yellow); + text-shadow: none; +} + +/* ── OR Button on Patient Chart ── */ +body[data-mode="jeff"] .chart-operate-banner { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 10px; + border: 0.5px solid var(--c-cyan); + border-radius: 6px; + background: rgba(0, 113, 227, 0.03); + text-align: center; +} + +body[data-mode="jeff"] .rx-action-operate { + font-family: var(--jeff-font) !important; + font-size: 12px !important; + font-weight: 600 !important; + padding: 8px 16px !important; + background: var(--c-cyan) !important; + color: #ffffff !important; + border: none !important; + border-radius: 6px !important; + letter-spacing: 0 !important; + box-shadow: none !important; +} + +body[data-mode="jeff"] .rx-action-operate::before { + display: none !important; +} + +body[data-mode="jeff"] .rx-action-operate:hover:not(:disabled) { + background: #0077ed !important; + color: #ffffff !important; + border-color: transparent !important; + box-shadow: none !important; + transform: none !important; +} + +body[data-mode="jeff"] .rx-action-operate:active:not(:disabled) { + background: #006adb !important; + transform: none !important; + box-shadow: none !important; +} + +body[data-mode="jeff"] .chart-operate-hint { + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-dim); + line-height: 1.4; +} + +/* ── NAK Command Blocks ── */ +body[data-mode="jeff"] .rx-nak-toggle, +body[data-mode="jeff"] .guide-nak-toggle { + padding: 2px 0 3px 14px; +} + +body[data-mode="jeff"] .nak-toggle-btn { + font-family: var(--jeff-font); + font-size: 10px; + padding: 1px 6px; + background: var(--c-bg); + border: 0.5px solid var(--c-border); + border-radius: 3px; + color: var(--c-dim); + cursor: pointer; + transition: all 0.15s; +} + +body[data-mode="jeff"] .nak-toggle-btn:hover { + color: var(--c-cyan); + border-color: var(--c-cyan); + background: rgba(0, 113, 227, 0.04); +} + +body[data-mode="jeff"] .rx-nak-container, +body[data-mode="jeff"] .guide-nak-container { + padding: 0 0 4px 14px; +} + +body[data-mode="jeff"] .nak-cmd-block { + border: 0.5px solid var(--c-border); + border-radius: 6px; + background: var(--c-bg); + margin-top: 3px; + overflow: hidden; +} + +body[data-mode="jeff"] .nak-cmd-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + background: var(--c-panel-bg); + border-bottom: 0.5px solid var(--c-border); +} + +body[data-mode="jeff"] .nak-cmd-label { + font-family: var(--jeff-font); + font-size: 10px; + font-weight: 500; + color: var(--c-dim); + letter-spacing: 0; +} + +body[data-mode="jeff"] .nak-copy-btn { + font-family: var(--jeff-font); + font-size: 10px; + font-weight: 500; + padding: 1px 6px; + background: var(--c-panel-bg); + border: 0.5px solid var(--c-cyan); + border-radius: 3px; + color: var(--c-cyan); + cursor: pointer; + transition: all 0.15s; +} + +body[data-mode="jeff"] .nak-copy-btn:hover { + background: var(--c-cyan); + color: #ffffff; +} + +body[data-mode="jeff"] .nak-cmd-desc { + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-dim); + padding: 4px 8px; + line-height: 1.4; +} + +body[data-mode="jeff"] .nak-cmd-pre { + padding: 6px 8px; +} + +body[data-mode="jeff"] .nak-cmd-pre code { + font-family: var(--jeff-font-mono); + font-size: 10px; + color: var(--c-white); + line-height: 1.5; +} + +/* ── Doctor's Notes ── */ +body[data-mode="jeff"] .chart-doctor-notes { + padding: 4px 6px; +} + +body[data-mode="jeff"] .doctor-note-text { + font-family: var(--jeff-font); + font-size: 11px; + line-height: 1.45; + color: var(--c-dim); + border-left: 2px solid var(--c-border); + font-style: normal; +} + +body[data-mode="jeff"] .dx-hint { + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-dim); + border-left: 1px solid var(--c-border); + font-style: normal; +} + +/* ── Findings groups ── */ +body[data-mode="jeff"] .dx-group-label { + font-family: var(--jeff-font); + font-size: 10px; + letter-spacing: 0; +} + +body[data-mode="jeff"] .dx-group-label.dx-fail { + color: var(--c-red); +} + +body[data-mode="jeff"] .dx-group-label.dx-warn { + color: var(--c-yellow); +} + +body[data-mode="jeff"] .dx-group-label.dx-pass { + color: var(--c-green); +} + +body[data-mode="jeff"] .dx-group-count { + font-size: 10px; +} + +body[data-mode="jeff"] .dx-group-toggle { + font-size: 10px; +} + + +/* ══════════════════════════════════════════ + GUIDED MODE β€” Clean step-by-step cards +══════════════════════════════════════════ */ + +body[data-mode="jeff"] .guide-card { + border: 0.5px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel-bg); + margin-bottom: 8px; + box-shadow: 0 1px 2px var(--c-shadow); + width: 50%; +} + +body[data-mode="jeff"] .guide-card-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + background: var(--c-bg); + border-bottom: 0.5px solid var(--c-border); + border-radius: 8px 8px 0 0; + font-family: var(--jeff-font); + font-size: 11px; +} + +body[data-mode="jeff"] .guide-card-icon { + font-size: 13px; +} + +body[data-mode="jeff"] .guide-card-title { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 600; + letter-spacing: 0; + color: var(--c-white); + text-transform: uppercase; +} + +body[data-mode="jeff"] .guide-card-progress { + margin-left: auto; + font-family: var(--jeff-font-mono); + font-size: 10px; + color: var(--c-cyan); + font-weight: 500; +} + +body[data-mode="jeff"] .guide-card-kind { + font-family: var(--jeff-font-mono); + font-size: 9px; + background: rgba(0, 113, 227, 0.08); + padding: 1px 5px; + border-radius: 3px; + color: var(--c-cyan); + border: 0.5px solid rgba(0, 113, 227, 0.2); +} + +body[data-mode="jeff"] .guide-card-body { + padding: 10px; +} + +body[data-mode="jeff"] .guide-prescription { + display: flex; + align-items: flex-start; + gap: 6px; + font-family: var(--jeff-font); + font-size: 12px; + line-height: 1.45; +} + +body[data-mode="jeff"] .guide-rx-text { + color: var(--c-white); +} + +body[data-mode="jeff"] .guide-tag { + font-family: var(--jeff-font); + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0; +} + +body[data-mode="jeff"] .guide-tag-actionable { + background: rgba(36, 138, 61, 0.08); + color: var(--c-green); + border: 0.5px solid rgba(36, 138, 61, 0.3); +} + +body[data-mode="jeff"] .guide-tag-manual { + background: rgba(191, 134, 0, 0.08); + color: var(--c-yellow); + border: 0.5px solid rgba(191, 134, 0, 0.3); +} + +body[data-mode="jeff"] .guide-tag-client { + background: rgba(215, 0, 21, 0.08); + color: var(--c-red); + border: 0.5px solid rgba(215, 0, 21, 0.3); +} + +body[data-mode="jeff"] .guide-recommendation { + margin-top: 6px; + font-family: var(--jeff-font); + font-size: 11px; + color: var(--c-dim); + border-left: 2px solid var(--c-border); + padding-left: 8px; + line-height: 1.45; + font-style: normal; +} + +body[data-mode="jeff"] .guide-doctor-note { + margin-top: 4px; + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-dim); + opacity: 1; +} + +body[data-mode="jeff"] .guide-card-actions { + display: flex; + gap: 4px; + padding: 6px 10px; + border-top: 0.5px solid var(--c-border); +} + +body[data-mode="jeff"] .guide-btn { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 500; + padding: 4px 10px; + border: 0.5px solid var(--c-border); + border-radius: 4px; + background: var(--c-panel-bg); + color: var(--c-dim); + cursor: pointer; + letter-spacing: 0; + transition: all 0.15s; +} + +body[data-mode="jeff"] .guide-btn:hover { + background: var(--c-bg); + color: var(--c-white); +} + +body[data-mode="jeff"] .guide-btn-next { + margin-left: auto; + background: var(--c-cyan); + border-color: var(--c-cyan); + color: #ffffff; +} + +body[data-mode="jeff"] .guide-btn-next:hover { + background: #0077ed; + color: #ffffff; +} + +body[data-mode="jeff"] .guide-btn-fix { + background: var(--c-panel-bg); + border-color: var(--c-green); + color: var(--c-green); + font-weight: 600; + letter-spacing: 0; +} + +body[data-mode="jeff"] .guide-btn-fix:hover:not(:disabled) { + background: rgba(36, 138, 61, 0.06); + color: var(--c-green); +} + +body[data-mode="jeff"] .guide-btn-fix:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +body[data-mode="jeff"] .guide-btn-skip { + opacity: 0.7; +} + +body[data-mode="jeff"] .guide-btn-manual { + font-size: 10px; + opacity: 0.6; +} + +/* ── Guide resume bar ── */ +body[data-mode="jeff"] .guide-resume-bar { + position: sticky; + top: 0; + z-index: 5; + padding: 6px; + background: var(--c-panel-bg); + border-bottom: 0.5px solid var(--c-cyan); + backdrop-filter: blur(8px); +} + +body[data-mode="jeff"] .guide-btn-resume { + font-family: var(--jeff-font); + font-size: 11px; + font-weight: 500; + padding: 6px 12px; + border: 0.5px solid var(--c-cyan); + border-radius: 5px; + background: rgba(0, 113, 227, 0.04); + color: var(--c-cyan); + cursor: pointer; + width: 100%; + text-align: center; + letter-spacing: 0; + transition: all 0.15s; + animation: none; +} + +body[data-mode="jeff"] .guide-btn-resume:hover { + background: rgba(0, 113, 227, 0.08); + color: var(--c-cyan); +} + +/* ── Relay collapse toggle ── */ +body[data-mode="jeff"] .relay-collapse-toggle { + font-family: var(--jeff-font); + font-size: 10px; + color: var(--c-dim); + background: none; + border: 0.5px solid var(--c-border); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; +} + +body[data-mode="jeff"] .relay-collapse-toggle:hover { + color: var(--c-cyan); + border-color: var(--c-cyan); +} + + +/* ══════════════════════════════════════════ + MOBILE β€” Jeff Mode +══════════════════════════════════════════ */ +@media (max-width: 768px) { + body[data-mode="jeff"] { + font-size: 13px; + } + + body[data-mode="jeff"] #screen-viewport { + padding: 12px 8px; + } + + body[data-mode="jeff"] #game-wrap { + border-radius: 0; + box-shadow: none; + } + + body[data-mode="jeff"] #top-bar { + border-radius: 8px; + padding: 10px 16px; + } + + body[data-mode="jeff"] .title-pixel { + font-size: 14px; + } + + body[data-mode="jeff"] .subtitle-pixel { + display: none; + } + + body[data-mode="jeff"] #sprite-panel { + display: none; + } + + body[data-mode="jeff"] #status-panel-title { + padding: 8px 16px 4px; + font-size: 10px; + } + + body[data-mode="jeff"] #relay-list { + padding: 10px 12px 12px; + gap: 8px; + height: 45vh; + max-height: 360px; + } + + body[data-mode="jeff"] .relay-group, + body[data-mode="jeff"] .result-section, + body[data-mode="jeff"] .patient-chart { + flex: 0 0 240px; + min-width: 220px; + } + + body[data-mode="jeff"] .patient-chart { + flex-basis: 280px; + min-width: 260px; + } + + body[data-mode="jeff"] #dialog-box { + padding: 10px 16px; + } + + body[data-mode="jeff"] #dialog-text { + font-size: 12px; + } + + body[data-mode="jeff"] #input-row { + flex-direction: column; + align-items: stretch; + padding: 10px 16px; + gap: 0; + } + + body[data-mode="jeff"] .input-inner { + flex-direction: column; + } + + body[data-mode="jeff"] #npub-input { + font-size: 16px; + padding: 10px 12px; + border-radius: 6px 6px 0 0; + border-right: 0.5px solid var(--c-border); + border-bottom: none; + } + + body[data-mode="jeff"] #audit-btn { + font-size: 13px; + padding: 10px 16px; + border-radius: 0 0 6px 6px; + width: 100%; + } + + body[data-mode="jeff"] #signer-box { + border-radius: 0; + padding: 8px 16px; + } + + body[data-mode="jeff"] .chart-header { + flex-direction: column; + gap: 2px; + padding: 6px 8px; + } + + body[data-mode="jeff"] .chart-header-right { + text-align: left; + } + + body[data-mode="jeff"] .chart-body { + padding: 8px; + gap: 8px; + } + + body[data-mode="jeff"] .rx-row { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + + body[data-mode="jeff"] .rx-action-btn { + justify-self: start; + } + + body[data-mode="jeff"] #jeff-footer { + padding: 12px 16px 20px; + } + + /* ── Operating Room mobile ── */ + body[data-mode="jeff"] .or-editor-header { + padding: 8px 10px; + } + + body[data-mode="jeff"] .or-editor-title { + font-size: 12px; + } + + body[data-mode="jeff"] .or-tray-header { + padding: 6px 10px; + gap: 6px; + } + + body[data-mode="jeff"] .or-tray-title { + font-size: 10px; + } + + body[data-mode="jeff"] .or-tray-body { + padding: 4px 8px; + } + + body[data-mode="jeff"] .or-relay-row { + grid-template-columns: 12px 6px 1fr auto auto; + gap: 4px; + padding: 4px 6px; + font-size: 10px; + } + + body[data-mode="jeff"] .or-relay-latency { + display: block; + width: 100%; + padding-left: 18px; + margin-top: 2px; + } + + body[data-mode="jeff"] .or-relay-hp { + width: 100%; + padding-left: 18px; + margin-top: 2px; + } + + body[data-mode="jeff"] .or-rw-btn { + font-size: 8px; + padding: 1px 4px; + } + + body[data-mode="jeff"] .or-add-input { + font-size: 10px; + padding: 6px 8px; + } + + body[data-mode="jeff"] .or-add-btn { + font-size: 10px; + padding: 6px 8px; + } + + body[data-mode="jeff"] .or-operate-btn { + font-size: 12px; + padding: 8px 14px; + } + + body[data-mode="jeff"] .or-close-btn { + font-size: 11px; + padding: 6px 10px; + } + + body[data-mode="jeff"] .or-diff-row { + font-size: 10px; + } + + /* ── Guided mode mobile ── */ + body[data-mode="jeff"] .guide-card-header { + font-size: 10px; + padding: 6px 8px; + } + + body[data-mode="jeff"] .guide-card-body { + padding: 8px; + } + + body[data-mode="jeff"] .guide-prescription { + font-size: 11px; + flex-wrap: wrap; + } + + body[data-mode="jeff"] .guide-tag { + font-size: 8px; + } + + body[data-mode="jeff"] .guide-recommendation { + font-size: 10px; + } + + body[data-mode="jeff"] .guide-card-actions { + padding: 4px 8px; + flex-wrap: wrap; + } + + body[data-mode="jeff"] .guide-btn { + font-size: 10px; + padding: 3px 8px; + } + + body[data-mode="jeff"] .guide-resume-bar { + padding: 4px 6px; + } + + body[data-mode="jeff"] .guide-btn-resume { + font-size: 10px; + } + + /* ── NAK commands mobile ── */ + body[data-mode="jeff"] .nak-cmd-pre code { + font-size: 9px; + } + + body[data-mode="jeff"] .nak-toggle-btn { + font-size: 9px; + } +} \ No newline at end of file diff --git a/js/personalities.js b/js/personalities.js index 27e1b98..32ee7fe 100644 --- a/js/personalities.js +++ b/js/personalities.js @@ -645,6 +645,33 @@ const LINES = { doctorNoteMarmot: "Marmot Protocol configuration incomplete. Publish kind 10050 and/or 10051 to enable encrypted messaging.", doctorNoteKeyPackage: "KeyPackage validation failures per MIP-00/01. Originating client needs to republish corrected KeyPackages.", doctorNoteMultiple: "{categoryCount} categories of issues detected. Address relay configuration and sync first, then Marmot Protocol setup.", + // ── Operating Room ── + orIntro: "Opening relay editor. Staged changes are applied only when you confirm.", + orRequiresNip07: "Relay editing requires NIP-07 for signing. Sign in first.", + orHealthStart: "Testing relay connectivity and latency.", + orHealthGood: "All {count} relay(s) responding. Ready to proceed.", + orHealthSlow: "{count} relay(s) slow. Edits will still apply.", + orHealthDead: "{count} relay(s) unreachable. Consider removing them.", + orDiscard: "All staged changes discarded.", + orClose: "Relay editor closed.", + orOperateStart: "Publishing {count} procedure(s) from {changes} staged change(s). Approve each NIP-07 signature request.", + orProcedure: "Procedure {index} of {total}: {name}.", + orProcedureOk: "{name} published to {succeeded} relay(s).", + orProcedureFail: "{name} failed on {failed} relay(s).", + orSuccess: "All {procedures} procedure(s) published to {relays} relay(s).", + orPartial: "Partial success: {succeeded} relays updated, {failed} failed.", + orFail: "Publishing failed. No relays accepted changes. Check NIP-07 and relay connectivity.", + // ── OR Briefing & Auto-Stage ── + orBriefingAutoStaged: "{staged} fix(es) pre-staged from diagnostic results. Review in the trays below.", + orBriefingSuggestions: "{count} item(s) require manual review.", + orBriefingGuided: "{steps}-step treatment plan available. Follow the guided flow or switch to manual editing.", + // ── Guided Mode ── + guideStepNote: "Step {step} of {total}.", + guideStepAdvance: "Step {step} of {total}.", + guideStepSkip: "Skipped.", + guideComplete: "All steps reviewed. Stage any remaining changes, then publish.", + guideManualMode: "Manual mode. Edit relay lists directly.", + guideResume: "Resuming guided flow.", }, house: { intro: "Give me an npub. I'll run a diagnostic. Everybody lies, especially metadata. *leans on cane*", diff --git a/style.css b/style.css index 2484c2f..042644d 100644 --- a/style.css +++ b/style.css @@ -1168,8 +1168,15 @@ body { } @keyframes nip07-pulse { - 0%, 100% { opacity: 0; } - 50% { opacity: 0.6; } + + 0%, + 100% { + opacity: 0; + } + + 50% { + opacity: 0.6; + } } #cancel-btn { @@ -1494,23 +1501,53 @@ body { /* ── Keyframes ── */ @keyframes or-cursor-bob { - 0%, 100% { transform: translateX(0); } - 50% { transform: translateX(0.375ch); } + + 0%, + 100% { + transform: translateX(0); + } + + 50% { + transform: translateX(0.375ch); + } } @keyframes or-heartbeat { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.2); opacity: 0.7; } + + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.2); + opacity: 0.7; + } } @keyframes or-slide-in { - 0% { transform: translateY(0.75ch); opacity: 0; } - 100% { transform: translateY(0); opacity: 1; } + 0% { + transform: translateY(0.75ch); + opacity: 0; + } + + 100% { + transform: translateY(0); + opacity: 1; + } } @keyframes or-staged-glow { - 0%, 100% { box-shadow: inset 0 0 0 0.125ch rgba(56, 232, 88, 0.15); } - 50% { box-shadow: inset 0 0 0 0.125ch rgba(56, 232, 88, 0.4), 0 0 0.75ch rgba(56, 232, 88, 0.15); } + + 0%, + 100% { + box-shadow: inset 0 0 0 0.125ch rgba(56, 232, 88, 0.15); + } + + 50% { + box-shadow: inset 0 0 0 0.125ch rgba(56, 232, 88, 0.4), 0 0 0.75ch rgba(56, 232, 88, 0.15); + } } /* ── Editor Container: SNES menu frame ── */ @@ -1546,8 +1583,15 @@ body { text-shadow: 0 0 0.75ch var(--c-yellow); } -.or-editor-header::before { top: -0.25ch; left: 0.5ch; } -.or-editor-header::after { top: -0.25ch; right: 0.5ch; } +.or-editor-header::before { + top: -0.25ch; + left: 0.5ch; +} + +.or-editor-header::after { + top: -0.25ch; + right: 0.5ch; +} .or-editor-title { font-size: 1.25ch; @@ -1575,10 +1619,21 @@ body { transition: border-color 0.2s, box-shadow 0.2s; } -.or-tray:nth-child(2) { animation-delay: 0.05s; } -.or-tray:nth-child(3) { animation-delay: 0.1s; } -.or-tray:nth-child(4) { animation-delay: 0.15s; } -.or-tray:nth-child(5) { animation-delay: 0.2s; } +.or-tray:nth-child(2) { + animation-delay: 0.05s; +} + +.or-tray:nth-child(3) { + animation-delay: 0.1s; +} + +.or-tray:nth-child(4) { + animation-delay: 0.15s; +} + +.or-tray:nth-child(5) { + animation-delay: 0.2s; +} .or-tray:hover { border-color: var(--c-border2); @@ -1851,9 +1906,17 @@ body { text-align: right; } -.or-health-ok .or-relay-latency { color: var(--c-green); } -.or-health-warn .or-relay-latency { color: var(--c-yellow); } -.or-health-dead .or-relay-latency { color: var(--c-red); } +.or-health-ok .or-relay-latency { + color: var(--c-green); +} + +.or-health-warn .or-relay-latency { + color: var(--c-yellow); +} + +.or-health-dead .or-relay-latency { + color: var(--c-red); +} /* Mini HP bar per relay: SNES-style stat bar */ .or-relay-hp { @@ -2431,10 +2494,22 @@ body { gap: 0.5ch; } -.dx-group-label.dx-fail { color: var(--c-red); } -.dx-group-label.dx-warn { color: var(--c-yellow); } -.dx-group-label.dx-pass { color: var(--c-green); cursor: pointer; } -.dx-group-label.dx-pass:hover { color: var(--c-white); } +.dx-group-label.dx-fail { + color: var(--c-red); +} + +.dx-group-label.dx-warn { + color: var(--c-yellow); +} + +.dx-group-label.dx-pass { + color: var(--c-green); + cursor: pointer; +} + +.dx-group-label.dx-pass:hover { + color: var(--c-white); +} .dx-group-count { font-size: 0.625ch; @@ -2480,8 +2555,8 @@ body { gap: 0.5ch; padding: 0.5ch 0.75ch; background: linear-gradient(180deg, - rgba(56, 232, 232, 0.15), - rgba(56, 232, 232, 0.05)); + rgba(56, 232, 232, 0.15), + rgba(56, 232, 232, 0.05)); border-bottom: 0.125ch solid var(--c-border); font-size: 0.875ch; } @@ -2513,6 +2588,8 @@ body { .guide-card-body { padding: 0.75ch; + overflow-y: scroll; + max-height: 100%; } .guide-prescription { @@ -2678,8 +2755,15 @@ body { } @keyframes guide-pulse { - 0%, 100% { box-shadow: 0 0 0.5ch rgba(56, 232, 232, 0.2); } - 50% { box-shadow: 0 0 1.5ch rgba(56, 232, 232, 0.4); } + + 0%, + 100% { + box-shadow: 0 0 0.5ch rgba(56, 232, 232, 0.2); + } + + 50% { + box-shadow: 0 0 1.5ch rgba(56, 232, 232, 0.4); + } } /* ── Tray highlight for guided mode ── */ @@ -2690,10 +2774,10 @@ body { border-color: var(--c-accent, #38e8e8) !important; } -.or-tray-highlighted > .or-tray-header { +.or-tray-highlighted>.or-tray-header { background: linear-gradient(180deg, - rgba(56, 232, 232, 0.15), - rgba(56, 232, 232, 0.05)); + rgba(56, 232, 232, 0.15), + rgba(56, 232, 232, 0.05)); } /* ── Relay rows collapse (after chart renders) ── */ @@ -3309,4 +3393,4 @@ body { .guide-btn-resume { font-size: 7px; } -} +} \ No newline at end of file