feat: Timmy Nostr identity — keypair, zaps, vouching, economic panel (#13)
Some checks failed
CI / validate (pull_request) Has been cancelled

- nostr.js (new): sovereign secp256k1 keypair with localStorage persistence,
  NIP-01 text notes, NIP-57 zap requests (send/receive), NIP-58 badge award
  vouching, relay WebSocket broadcast, activity feed with nostr:activity events
- app.js: import nostr.js; initNostr() on boot; 3D holographic ECONOMIC panel
  (canvas texture) positioned at (10,0,-2) showing live npub + activity feed;
  chat commands /identity /note /zap /vouch /zapin /relays /help
- index.html: nostr-tools@2 in importmap; Nostr identity HUD widget (below
  agent log); Zap modal; Vouch modal
- style.css: Nostr widget, activity feed, zap flash animation, modal styles

Fixes #13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 23:25:10 -04:00
parent 75c9a3774b
commit 2b0d8e1b92
4 changed files with 661 additions and 2 deletions

257
app.js
View File

@@ -3,6 +3,7 @@ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { initNostr, getIdentity, getActivityFeed, publishNote, sendZap, recordZapIn, vouchFor } from './nostr.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -124,6 +125,7 @@ async function init() {
createThoughtStream();
createHarnessPulse();
createSessionPowerMeter();
createEconomicPanel();
updateLoad(90);
composer = new EffectComposer(renderer);
@@ -141,6 +143,16 @@ async function init() {
window.addEventListener('resize', onResize);
debugOverlay = document.getElementById('debug-overlay');
// ═══ NOSTR IDENTITY ═══
try {
const identity = initNostr();
setupNostrHUD(identity);
setupNostrModals();
addChatMessage('system', `⚡ Nostr identity loaded. npub: ${identity.npub.slice(0, 16)}...`);
} catch (e) {
console.warn('[NOSTR] Identity init failed:', e);
}
updateLoad(100);
setTimeout(() => {
@@ -1065,6 +1077,68 @@ function sendChatMessage() {
if (!text) return;
addChatMessage('user', text);
input.value = '';
input.blur();
// ═══ NOSTR COMMANDS ═══
if (text.startsWith('/')) {
const [cmd, ...args] = text.slice(1).split(' ');
switch (cmd.toLowerCase()) {
case 'identity': {
try {
const id = getIdentity();
addChatMessage('system', `npub: ${id.npub}`);
addChatMessage('system', `relays: ${id.relays.join(', ')}`);
} catch (_) { addChatMessage('error', 'Identity not loaded.'); }
return;
}
case 'note': {
const content = args.join(' ');
if (!content) { addChatMessage('error', 'Usage: /note <text>'); return; }
publishNote(content).then(() => addChatMessage('timmy', `📝 Note published: "${content}"`));
return;
}
case 'zap': {
// /zap <recipient_hex> <amount> [comment]
const [recipient, amountStr, ...commentParts] = args;
if (!recipient || !amountStr) { addChatMessage('error', 'Usage: /zap <pubkey_hex> <sats> [comment]'); return; }
const amount = parseInt(amountStr, 10);
sendZap(recipient, amount, commentParts.join(' ')).then(() =>
addChatMessage('timmy', `⚡ Zap sent: ${amount} sats to ${recipient.slice(0, 12)}...`)
);
return;
}
case 'vouch': {
// /vouch <pubkey_hex> <badge> [note]
const [pubkey, badge, ...noteParts] = args;
if (!pubkey || !badge) { addChatMessage('error', 'Usage: /vouch <pubkey_hex> <badge_name> [note]'); return; }
vouchFor(pubkey, badge, noteParts.join(' ')).then(() =>
addChatMessage('timmy', `🏅 Vouched for ${pubkey.slice(0, 12)}... — badge: ${badge}`)
);
return;
}
case 'relays': {
try {
const id = getIdentity();
id.relays.forEach(r => addChatMessage('system', `relay: ${r}`));
} catch (_) { addChatMessage('error', 'Identity not loaded.'); }
return;
}
case 'zapin': {
// /zapin <amount> <from_label> — simulate incoming zap
const [amountStr, ...fromParts] = args;
const amount = parseInt(amountStr || '21', 10);
const from = fromParts.join(' ') || 'unknown';
recordZapIn(amount, from, 'simulated');
addChatMessage('timmy', `⚡ Incoming zap recorded: +${amount} sats from ${from}`);
return;
}
case 'help': {
addChatMessage('system', 'Commands: /identity /note <text> /zap <key> <sats> [msg] /vouch <key> <badge> [note] /zapin <sats> <from> /relays');
return;
}
}
}
setTimeout(() => {
const responses = [
'Processing your request through the harness...',
@@ -1078,7 +1152,6 @@ function sendChatMessage() {
const resp = responses[Math.floor(Math.random() * responses.length)];
addChatMessage('timmy', resp);
}, 500 + Math.random() * 1000);
input.blur();
}
function addChatMessage(type, text) {
@@ -1459,6 +1532,188 @@ function simulateAgentThought() {
addAgentLog(agentId, thought);
}
// ═══ ECONOMIC PANEL (3D) ═══
function createEconomicPanel() {
const group = new THREE.Group();
group.position.set(10, 0, -2);
group.rotation.y = -0.6;
const w = 3.2, h = 4.2;
const bg = new THREE.Mesh(
new THREE.PlaneGeometry(w, h),
new THREE.MeshPhysicalMaterial({
color: NEXUS.colors.panelBg, transparent: true, opacity: 0.55,
roughness: 0.1, metalness: 0.5, side: THREE.DoubleSide,
})
);
group.add(bg);
const border = new THREE.Mesh(
new THREE.PlaneGeometry(w + 0.06, h + 0.06),
new THREE.MeshBasicMaterial({ color: NEXUS.colors.gold, transparent: true, opacity: 0.25, side: THREE.DoubleSide })
);
border.position.z = -0.01;
group.add(border);
// Canvas texture
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 672;
const ctx = canvas.getContext('2d');
function redrawEconomicCanvas() {
ctx.clearRect(0, 0, 512, 672);
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 28px "Orbitron", sans-serif';
ctx.fillText('ECONOMIC', 20, 40);
ctx.fillRect(20, 50, 472, 2);
let identity;
try { identity = getIdentity(); } catch (_) { identity = null; }
const npub = identity ? identity.npub : 'npub1...';
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = '#a0b8d0';
ctx.fillText('> npub:', 20, 85);
ctx.fillStyle = '#4af0c0';
ctx.fillText(npub.slice(0, 24) + '...', 20, 103);
ctx.fillStyle = '#a0b8d0';
ctx.fillText('> ACTIVITY FEED', 20, 135);
ctx.fillRect(20, 143, 472, 1);
let feed;
try { feed = getActivityFeed().slice(0, 8); } catch (_) { feed = []; }
feed.forEach((entry, i) => {
const y = 165 + i * 58;
let tag = '', tagColor = '#a0b8d0', detail = '';
if (entry.type === 'zap_in') { tag = '⚡ ZAP IN'; tagColor = '#ffd700'; detail = `+${entry.amount} sats from ${entry.from || '?'}`; }
if (entry.type === 'zap_out') { tag = '⚡ ZAP OUT'; tagColor = '#ffaa22'; detail = `-${entry.amount} sats to ${entry.to || '?'}`; }
if (entry.type === 'vouch') { tag = '🏅 VOUCH'; tagColor = '#7b5cff'; detail = `${entry.target || '?'}: ${entry.note || ''}`; }
if (entry.type === 'note') { tag = '📝 NOTE'; tagColor = '#4af0c0'; detail = entry.content || ''; }
ctx.fillStyle = tagColor;
ctx.font = 'bold 14px "JetBrains Mono", monospace';
ctx.fillText(tag, 20, y);
ctx.fillStyle = '#8090a0';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.fillText(detail.slice(0, 40), 20, y + 17);
});
}
redrawEconomicCanvas();
const texture = new THREE.CanvasTexture(canvas);
const textMesh = new THREE.Mesh(
new THREE.PlaneGeometry(w - 0.2, h - 0.2),
new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide })
);
textMesh.position.z = 0.01;
group.add(textMesh);
group.userData.redraw = () => { redrawEconomicCanvas(); texture.needsUpdate = true; };
scene.add(group);
batcaveTerminals.push(group);
// Redraw when activity updates
document.addEventListener('nostr:activity', () => {
group.userData.redraw();
});
}
// ═══ NOSTR HUD ═══
function setupNostrHUD(identity) {
const npubEl = document.getElementById('nostr-npub');
if (npubEl) {
npubEl.textContent = identity.npub.slice(0, 28) + '...';
npubEl.title = identity.npub;
npubEl.addEventListener('click', () => {
navigator.clipboard?.writeText(identity.npub).catch(() => {});
addChatMessage('system', 'npub copied to clipboard.');
});
}
function renderFeed() {
const container = document.getElementById('nostr-activity-feed');
if (!container) return;
container.innerHTML = '';
getActivityFeed().slice(0, 5).forEach(entry => {
const div = document.createElement('div');
div.className = 'nostr-activity-entry';
let tag = '', detail = '';
if (entry.type === 'zap_in') { tag = 'ZAP IN'; detail = `+${entry.amount}⚡ from ${entry.from || '?'}`; }
if (entry.type === 'zap_out') { tag = 'ZAP OUT'; detail = `-${entry.amount}⚡ to ${entry.to || '?'}`; }
if (entry.type === 'vouch') { tag = 'VOUCH'; detail = `${entry.target || '?'}`; }
if (entry.type === 'note') { tag = 'NOTE'; detail = (entry.content || '').slice(0, 30); }
div.innerHTML = `<span class="nostr-tag nostr-tag-${entry.type}">[${tag}]</span>${detail}`;
container.appendChild(div);
});
}
renderFeed();
document.addEventListener('nostr:activity', () => {
renderFeed();
const widget = document.getElementById('nostr-widget');
if (widget) {
widget.classList.remove('zap-flash');
void widget.offsetWidth; // reflow
widget.classList.add('zap-flash');
}
});
}
function setupNostrModals() {
// Zap modal
const zapBtn = document.getElementById('nostr-zap-btn');
const zapModal = document.getElementById('zap-modal');
const zapClose = document.getElementById('zap-modal-close');
const zapSend = document.getElementById('zap-send-btn');
zapBtn?.addEventListener('click', () => { if (zapModal) zapModal.style.display = 'flex'; });
zapClose?.addEventListener('click', () => { if (zapModal) zapModal.style.display = 'none'; });
zapSend?.addEventListener('click', async () => {
const recipient = document.getElementById('zap-recipient')?.value.trim();
const amount = parseInt(document.getElementById('zap-amount')?.value || '0', 10);
const comment = document.getElementById('zap-comment')?.value.trim() || '';
if (!recipient || !amount) { addChatMessage('error', 'ZAP: recipient and amount required.'); return; }
// Resolve npub → hex if needed
let hexKey = recipient;
if (recipient.startsWith('npub1')) {
try {
const { nip19 } = await import('nostr-tools');
hexKey = nip19.decode(recipient).data;
} catch (_) { addChatMessage('error', 'Invalid npub.'); return; }
}
await sendZap(hexKey, amount, comment);
addChatMessage('timmy', `⚡ Zap sent: ${amount} sats.`);
if (zapModal) zapModal.style.display = 'none';
});
// Vouch modal
const vouchBtn = document.getElementById('nostr-vouch-btn');
const vouchModal = document.getElementById('vouch-modal');
const vouchClose = document.getElementById('vouch-modal-close');
const vouchSend = document.getElementById('vouch-send-btn');
vouchBtn?.addEventListener('click', () => { if (vouchModal) vouchModal.style.display = 'flex'; });
vouchClose?.addEventListener('click', () => { if (vouchModal) vouchModal.style.display = 'none'; });
vouchSend?.addEventListener('click', async () => {
const pubkey = document.getElementById('vouch-pubkey')?.value.trim();
const badge = document.getElementById('vouch-badge')?.value.trim();
const note = document.getElementById('vouch-note')?.value.trim() || '';
if (!pubkey || !badge) { addChatMessage('error', 'VOUCH: pubkey and badge name required.'); return; }
await vouchFor(pubkey, badge, note);
addChatMessage('timmy', `🏅 Vouched for ${pubkey.slice(0, 12)}... with badge: ${badge}`);
if (vouchModal) vouchModal.style.display = 'none';
});
// Close modals on backdrop click
[zapModal, vouchModal].forEach(modal => {
modal?.addEventListener('click', e => {
if (e.target === modal) modal.style.display = 'none';
});
});
}
function addAgentLog(agentId, text) {
const container = document.getElementById('agent-log-content');
if (!container) return;

View File

@@ -27,7 +27,9 @@
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/",
"nostr-tools": "https://esm.sh/nostr-tools@2",
"nostr-tools/nip19": "https://esm.sh/nostr-tools@2/nip19"
}
}
</script>
@@ -80,6 +82,57 @@
<div id="agent-log-content" class="agent-log-content"></div>
</div>
<!-- Nostr Identity Widget (below agent log) -->
<div id="nostr-widget" class="nostr-widget">
<div class="nostr-widget-header">
<span class="nostr-widget-icon"></span>
<span class="nostr-widget-title">TIMMY IDENTITY</span>
<span id="nostr-status-dot" class="nostr-status-dot"></span>
</div>
<div id="nostr-npub" class="nostr-npub">npub1...</div>
<div id="nostr-activity-feed" class="nostr-activity-feed"></div>
<div class="nostr-widget-actions">
<button id="nostr-zap-btn" class="nostr-action-btn">⚡ ZAP</button>
<button id="nostr-vouch-btn" class="nostr-action-btn">🏅 VOUCH</button>
</div>
</div>
<!-- Zap Modal -->
<div id="zap-modal" class="nostr-modal" style="display:none;">
<div class="nostr-modal-content">
<div class="nostr-modal-header">
<span class="nostr-modal-icon"></span>
<h3>SEND ZAP</h3>
<button class="nostr-modal-close" id="zap-modal-close"></button>
</div>
<label class="nostr-modal-label">Recipient pubkey (hex or npub)</label>
<input type="text" id="zap-recipient" class="nostr-modal-input" placeholder="npub1...">
<label class="nostr-modal-label">Amount (sats)</label>
<input type="number" id="zap-amount" class="nostr-modal-input" placeholder="21" min="1">
<label class="nostr-modal-label">Comment</label>
<input type="text" id="zap-comment" class="nostr-modal-input" placeholder="Optional note...">
<button id="zap-send-btn" class="nostr-modal-submit">⚡ SEND ZAP</button>
</div>
</div>
<!-- Vouch Modal -->
<div id="vouch-modal" class="nostr-modal" style="display:none;">
<div class="nostr-modal-content">
<div class="nostr-modal-header">
<span class="nostr-modal-icon">🏅</span>
<h3>VOUCH FOR CONTRIBUTOR</h3>
<button class="nostr-modal-close" id="vouch-modal-close"></button>
</div>
<label class="nostr-modal-label">Contributor pubkey (hex)</label>
<input type="text" id="vouch-pubkey" class="nostr-modal-input" placeholder="hex pubkey...">
<label class="nostr-modal-label">Badge name</label>
<input type="text" id="vouch-badge" class="nostr-modal-input" placeholder="e.g. Sovereign Builder">
<label class="nostr-modal-label">Note</label>
<input type="text" id="vouch-note" class="nostr-modal-input" placeholder="Optional note...">
<button id="vouch-send-btn" class="nostr-modal-submit">🏅 VOUCH</button>
</div>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">

168
nostr.js Normal file
View File

@@ -0,0 +1,168 @@
// ═══════════════════════════════════════════
// NOSTR.JS — Timmy's Sovereign Identity
// NIP-01: Text Notes NIP-57: Zaps NIP-58: Badge Awards
// ═══════════════════════════════════════════
import { generateSecretKey, getPublicKey, finalizeEvent, nip19 } from 'nostr-tools';
const STORAGE_KEY = 'timmy_nostr_sk';
export const DEFAULT_RELAYS = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://relay.snort.social',
];
let _sk = null;
let _pk = null;
let _npub = null;
let _nsec = null;
let _relays = [...DEFAULT_RELAYS];
const _activity = [];
const _sockets = new Map();
// ═══ INIT ═══
export function initNostr() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
_sk = Uint8Array.from(JSON.parse(stored));
} catch (_) {
_sk = null;
}
}
if (!_sk) {
_sk = generateSecretKey();
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(_sk)));
}
_pk = getPublicKey(_sk);
_npub = nip19.npubEncode(_pk);
_nsec = nip19.nsecEncode(_sk);
// Seed feed with plausible history
const now = Math.floor(Date.now() / 1000);
_activity.push(
{ type: 'note', content: 'Nexus sovereign space initialized.', time: now - 7200 },
{ type: 'zap_in', amount: 21, from: 'npub1alex...', note: 'Inception zap', time: now - 3600 },
{ type: 'zap_in', amount: 100, from: 'npub1satoshi...', note: 'For the work', time: now - 1800 },
{ type: 'vouch', target: 'alexander', note: 'Sovereign builder',time: now - 900 },
{ type: 'zap_out', amount: 50, to: 'npub1artist...', note: 'Creative signal', time: now - 120 },
);
console.log(`[NOSTR] Identity ready. npub: ${_npub}`);
return getIdentity();
}
// ═══ GETTERS ═══
export function getIdentity() {
return { pk: _pk, npub: _npub, nsec: _nsec, relays: [..._relays] };
}
export function getActivityFeed() {
return [..._activity];
}
// ═══ ACTIONS ═══
export async function publishNote(content) {
if (!_sk) return null;
const event = finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content,
}, _sk);
_broadcast(event);
const entry = { type: 'note', content, time: Math.floor(Date.now() / 1000) };
_recordActivity(entry);
return event;
}
export async function sendZap(recipientPubkeyHex, amountSats, comment = '') {
if (!_sk) return null;
const event = finalizeEvent({
kind: 9734,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', recipientPubkeyHex],
['amount', String(amountSats * 1000)],
['relays', ..._relays],
],
content: comment,
}, _sk);
_broadcast(event);
const entry = { type: 'zap_out', amount: amountSats, to: recipientPubkeyHex.slice(0, 12) + '...', note: comment, time: Math.floor(Date.now() / 1000) };
_recordActivity(entry);
return event;
}
export function recordZapIn(amountSats, fromLabel, note = '') {
const entry = { type: 'zap_in', amount: amountSats, from: fromLabel, note, time: Math.floor(Date.now() / 1000) };
_recordActivity(entry);
return entry;
}
export async function vouchFor(targetPubkeyHex, badgeName, note = '') {
if (!_sk) return null;
const slug = badgeName.toLowerCase().replace(/\s+/g, '-');
const badgeDef = finalizeEvent({
kind: 30009,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', slug],
['name', badgeName],
['description', note],
],
content: '',
}, _sk);
const badgeAward = finalizeEvent({
kind: 8,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', `30009:${_pk}:${slug}`],
['p', targetPubkeyHex],
],
content: note,
}, _sk);
_broadcast(badgeDef);
_broadcast(badgeAward);
const entry = { type: 'vouch', target: targetPubkeyHex.slice(0, 12) + '...', note, time: Math.floor(Date.now() / 1000) };
_recordActivity(entry);
return badgeAward;
}
// ═══ INTERNALS ═══
function _recordActivity(entry) {
_activity.unshift(entry);
if (_activity.length > 50) _activity.pop();
document.dispatchEvent(new CustomEvent('nostr:activity', { detail: entry }));
}
function _broadcast(event) {
for (const url of _relays) {
try {
let ws = _sockets.get(url);
if (!ws || ws.readyState > 1) {
ws = new WebSocket(url);
_sockets.set(url, ws);
ws.addEventListener('open', () => {
try { ws.send(JSON.stringify(['EVENT', event])); } catch (_) { /* silent */ }
});
ws.addEventListener('error', () => { /* silent — relay may be offline */ });
} else if (ws.readyState === 1) {
ws.send(JSON.stringify(['EVENT', event]));
}
} catch (_) {
// Relay unreachable — continue
}
}
}

183
style.css
View File

@@ -625,6 +625,189 @@ canvas#nexus-canvas {
color: var(--color-primary);
}
/* ═══ NOSTR IDENTITY WIDGET ═══ */
.nostr-widget {
position: absolute;
top: calc(var(--space-3) + 110px); /* below agent log */
right: var(--space-3);
width: 280px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
border-left: 2px solid #ffd700;
padding: var(--space-3);
font-size: 10px;
pointer-events: auto;
}
.nostr-widget-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: var(--space-2);
}
.nostr-widget-icon { font-size: 13px; }
.nostr-widget-title {
font-family: var(--font-display);
color: var(--color-gold);
letter-spacing: 0.1em;
opacity: 0.9;
flex: 1;
}
.nostr-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
animation: nostr-pulse 2s ease-in-out infinite;
}
@keyframes nostr-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.nostr-npub {
font-family: var(--font-body);
font-size: 9px;
color: var(--color-text-muted);
margin-bottom: var(--space-2);
word-break: break-all;
cursor: pointer;
}
.nostr-npub:hover { color: var(--color-primary); }
.nostr-activity-feed {
display: flex;
flex-direction: column;
gap: 3px;
min-height: 60px;
max-height: 120px;
overflow: hidden;
margin-bottom: var(--space-2);
}
.nostr-activity-entry {
animation: log-fade-in 0.4s ease-out forwards;
opacity: 0;
font-size: 9px;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nostr-activity-entry .nostr-tag { font-weight: 700; margin-right: 4px; }
.nostr-tag-zap_in { color: var(--color-gold); }
.nostr-tag-zap_out { color: var(--color-warning); }
.nostr-tag-vouch { color: var(--color-secondary); }
.nostr-tag-note { color: var(--color-primary); }
.nostr-widget-actions {
display: flex;
gap: var(--space-2);
}
.nostr-action-btn {
flex: 1;
background: transparent;
border: 1px solid rgba(255, 215, 0, 0.3);
color: var(--color-gold);
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.05em;
padding: 4px 0;
border-radius: 3px;
cursor: pointer;
transition: var(--transition-ui);
}
.nostr-action-btn:hover {
background: rgba(255, 215, 0, 0.1);
border-color: var(--color-gold);
}
/* Zap flash animation for new incoming zaps */
@keyframes zapFlash {
0% { box-shadow: 0 0 0 0 rgba(255,215,0,0.8); }
70% { box-shadow: 0 0 0 10px rgba(255,215,0,0); }
100% { box-shadow: 0 0 0 0 rgba(255,215,0,0); }
}
.nostr-widget.zap-flash {
animation: zapFlash 0.6s ease-out;
}
/* ═══ NOSTR MODALS ═══ */
.nostr-modal {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
pointer-events: auto;
}
.nostr-modal-content {
background: rgba(10, 15, 40, 0.95);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: var(--panel-radius);
padding: var(--space-6);
width: min(400px, 90vw);
display: flex;
flex-direction: column;
gap: var(--space-3);
box-shadow: 0 0 40px rgba(255, 215, 0, 0.1);
}
.nostr-modal-header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.nostr-modal-icon { font-size: 18px; }
.nostr-modal-header h3 {
font-family: var(--font-display);
color: var(--color-gold);
font-size: var(--text-base);
letter-spacing: 0.1em;
flex: 1;
}
.nostr-modal-close {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-lg);
}
.nostr-modal-close:hover { color: var(--color-danger); }
.nostr-modal-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.nostr-modal-input {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(74, 240, 192, 0.2);
border-radius: 4px;
color: var(--color-text-bright);
font-family: var(--font-body);
font-size: var(--text-sm);
padding: var(--space-2) var(--space-3);
outline: none;
transition: var(--transition-ui);
}
.nostr-modal-input:focus {
border-color: var(--color-gold);
box-shadow: 0 0 8px rgba(255,215,0,0.2);
}
.nostr-modal-submit {
background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(255,215,0,0.05));
border: 1px solid rgba(255,215,0,0.5);
border-radius: 4px;
color: var(--color-gold);
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.08em;
padding: var(--space-3) var(--space-4);
cursor: pointer;
transition: var(--transition-ui);
}
.nostr-modal-submit:hover {
background: rgba(255,215,0,0.2);
box-shadow: 0 0 16px rgba(255,215,0,0.3);
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {