diff --git a/app.js b/app.js index 60689a0..063a5bf 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,11 @@ 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, + createZapRequest, createVouch, createNote, createProfileEvent, + broadcastEvent, addActivity, getRecentActivity, RELAYS, +} from './nostr.js'; // ═══════════════════════════════════════════ // NEXUS v1 — Timmy's Sovereign Home @@ -34,6 +39,8 @@ let debugOverlay; let frameCount = 0, lastFPSTime = 0, fps = 0; let chatOpen = true; let loadProgress = 0; +let nostrIdentity = null; +let economicPanelData = null; // ═══ INIT ═══ function init() { @@ -77,6 +84,7 @@ function init() { createDustParticles(); updateLoad(85); createAmbientStructures(); + createEconomicPanel(); updateLoad(90); // Post-processing @@ -115,6 +123,37 @@ function init() { setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900); }, 600); + // Init Nostr identity (async — don't block scene start) + initNostr().then(identity => { + nostrIdentity = identity; + if (identity?.npub) { + updateNostrHUD(identity); + addChatMessage('system', `Nostr identity loaded: ${identity.npub.slice(0, 20)}…`); + } + }).catch(err => { + console.warn('[Nostr] Could not load identity:', err); + addChatMessage('system', 'Nostr: identity unavailable (relay-only mode)'); + }); + + // Listen for economic activity events + window.addEventListener('nostr:activity', (e) => { + const { type, data } = e.detail; + if (data?.label) { + addChatMessage('system', data.label); + } + refreshEconomicPanel(); + // Flash the Nostr HUD panel on incoming zaps + if (type === 'zap_in') { + const panel = document.getElementById('nostr-panel'); + if (panel) { + panel.classList.remove('zap-received'); + void panel.offsetWidth; // force reflow to restart animation + panel.classList.add('zap-received'); + setTimeout(() => panel.classList.remove('zap-received'), 700); + } + } + }); + // Start loop requestAnimationFrame(gameLoop); } @@ -844,6 +883,13 @@ function sendChatMessage() { addChatMessage('user', text); input.value = ''; + // Handle Nostr commands + if (text.startsWith('/')) { + handleNostrCommand(text); + input.blur(); + return; + } + // Simulate Timmy response setTimeout(() => { const responses = [ @@ -862,6 +908,99 @@ function sendChatMessage() { input.blur(); } +async function handleNostrCommand(text) { + const parts = text.slice(1).split(' '); + const cmd = parts[0].toLowerCase(); + + if (cmd === 'identity' || cmd === 'id') { + const id = getIdentity(); + if (id?.npub) { + addChatMessage('timmy', `My npub: ${id.npub}`); + addChatMessage('timmy', `Pubkey: ${id.pubkey.slice(0, 16)}…`); + } else { + addChatMessage('error', 'Nostr identity not loaded yet.'); + } + return; + } + + if (cmd === 'zap') { + // /zap [comment] + const target = parts[1]; + const sats = parseInt(parts[2]) || 21; + const comment = parts.slice(3).join(' '); + if (!target) { + addChatMessage('error', 'Usage: /zap [comment]'); + return; + } + try { + const event = createZapRequest(target, sats * 1000, comment); + addChatMessage('timmy', `⚡ Zap request created for ${sats} sats. Broadcasting…`); + const results = await broadcastEvent(event); + const ok = results.filter(r => r.ok).length; + addChatMessage('timmy', `Zap sent to ${ok}/${results.length} relays.`); + refreshEconomicPanel(); + } catch (err) { + addChatMessage('error', `Zap failed: ${err.message}`); + } + return; + } + + if (cmd === 'vouch') { + // /vouch [reason] + const target = parts[1]; + const badge = parts[2] || 'Trusted Builder'; + const reason = parts.slice(3).join(' ') || 'Vouched by Timmy from the Nexus'; + if (!target) { + addChatMessage('error', 'Usage: /vouch [reason]'); + return; + } + try { + const event = createVouch(target, badge, reason); + addChatMessage('timmy', `🏅 Vouch created for badge "${badge}". Broadcasting…`); + const results = await broadcastEvent(event); + const ok = results.filter(r => r.ok).length; + addChatMessage('timmy', `Vouch sent to ${ok}/${results.length} relays.`); + refreshEconomicPanel(); + } catch (err) { + addChatMessage('error', `Vouch failed: ${err.message}`); + } + return; + } + + if (cmd === 'note') { + // /note + const content = parts.slice(1).join(' '); + if (!content) { + addChatMessage('error', 'Usage: /note '); + return; + } + try { + const event = createNote(content); + addChatMessage('timmy', `📝 Note signed. Broadcasting…`); + const results = await broadcastEvent(event); + const ok = results.filter(r => r.ok).length; + addChatMessage('timmy', `Note published to ${ok}/${results.length} relays.`); + addActivity('note', { label: `📝 Published note to relays`, ts: Math.floor(Date.now() / 1000) }); + refreshEconomicPanel(); + } catch (err) { + addChatMessage('error', `Note failed: ${err.message}`); + } + return; + } + + if (cmd === 'relays') { + addChatMessage('timmy', `Relays: ${RELAYS.join(', ')}`); + return; + } + + if (cmd === 'help') { + addChatMessage('timmy', 'Nostr commands: /identity, /zap [msg], /vouch [reason], /note , /relays'); + return; + } + + addChatMessage('error', `Unknown command: /${cmd} — try /help`); +} + function addChatMessage(type, text) { const container = document.getElementById('chat-messages'); const div = document.createElement('div'); @@ -912,6 +1051,11 @@ function gameLoop() { if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed; }); + // Animate economic panel scanline + if (economicPanelData?.scanMat?.uniforms) { + economicPanelData.scanMat.uniforms.uTime.value = elapsed; + } + // Animate portal if (portalMesh) { portalMesh.rotation.z = elapsed * 0.3; @@ -970,6 +1114,199 @@ function gameLoop() { renderer.info.reset(); } +// ═══ NOSTR / ECONOMIC PANEL ═══ + +let _econPanelCanvas = null; +let _econPanelCtx = null; +let _econPanelTex = null; + +function createEconomicPanel() { + const group = new THREE.Group(); + // Place on the left side of the scene, facing inward toward the center + group.position.set(-16, 0, -8); + group.rotation.y = 0.55; + + const w = 4.5, h = 5.5; + + // Background + const bgMat = new THREE.MeshBasicMaterial({ + color: 0x000510, + transparent: true, + opacity: 0.75, + side: THREE.DoubleSide, + }); + group.add(new THREE.Mesh(new THREE.PlaneGeometry(w, h), bgMat)); + + // Border + const borderMat = new THREE.LineBasicMaterial({ + color: NEXUS.colors.gold, + transparent: true, + opacity: 0.7, + }); + group.add(new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, h)), + borderMat, + )); + + // Canvas texture for text content + _econPanelCanvas = document.createElement('canvas'); + _econPanelCanvas.width = 576; + _econPanelCanvas.height = 704; + _econPanelCtx = _econPanelCanvas.getContext('2d'); + + _econPanelTex = new THREE.CanvasTexture(_econPanelCanvas); + _econPanelTex.minFilter = THREE.LinearFilter; + + const textMat = new THREE.MeshBasicMaterial({ + map: _econPanelTex, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + }); + const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat); + textMesh.position.z = 0.01; + group.add(textMesh); + + // Scanline overlay (reuse same shader as batcave terminals) + const scanMat = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new THREE.Color(NEXUS.colors.gold) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uTime; + uniform vec3 uColor; + varying vec2 vUv; + void main() { + float scanline = sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5; + float flicker = 0.97 + 0.03 * sin(uTime * 13.0); + float alpha = scanline * 0.04 * flicker; + gl_FragColor = vec4(uColor, alpha); + } + `, + }); + const scanMesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), scanMat); + scanMesh.position.z = 0.02; + group.add(scanMesh); + + scene.add(group); + economicPanelData = { group, scanMat }; + + // Initial paint + _paintEconomicPanel([]); +} + +function _paintEconomicPanel(feed) { + if (!_econPanelCtx) return; + const ctx = _econPanelCtx; + const cw = _econPanelCanvas.width; + const ch = _econPanelCanvas.height; + const goldHex = '#' + new THREE.Color(NEXUS.colors.gold).getHexString(); + + ctx.clearRect(0, 0, cw, ch); + + // Title + ctx.font = 'bold 28px "JetBrains Mono", monospace'; + ctx.fillStyle = goldHex; + ctx.fillText('⚡ ECONOMIC', 20, 42); + + // Separator + ctx.strokeStyle = goldHex; + ctx.globalAlpha = 0.3; + ctx.beginPath(); + ctx.moveTo(20, 54); + ctx.lineTo(cw - 20, 54); + ctx.stroke(); + ctx.globalAlpha = 1; + + // Nostr identity + ctx.font = '16px "JetBrains Mono", monospace'; + ctx.fillStyle = '#5a6a8a'; + ctx.fillText('IDENTITY', 20, 80); + + const npub = nostrIdentity?.npub; + ctx.font = '18px "JetBrains Mono", monospace'; + ctx.fillStyle = '#a0b8d0'; + if (npub) { + ctx.fillText(npub.slice(0, 14) + '…', 20, 102); + ctx.fillText(npub.slice(-12), 20, 122); + } else { + ctx.fillStyle = '#5a6a8a'; + ctx.fillText('loading…', 20, 102); + } + + // Activity feed + ctx.font = '16px "JetBrains Mono", monospace'; + ctx.fillStyle = '#5a6a8a'; + ctx.fillText('RECENT ACTIVITY', 20, 155); + + ctx.strokeStyle = goldHex; + ctx.globalAlpha = 0.15; + ctx.beginPath(); + ctx.moveTo(20, 162); + ctx.lineTo(cw - 20, 162); + ctx.stroke(); + ctx.globalAlpha = 1; + + const entries = feed.length ? feed : [{ label: 'No activity yet…', type: 'none' }]; + ctx.font = '17px "JetBrains Mono", monospace'; + entries.slice(0, 7).forEach((item, i) => { + let color = '#a0b8d0'; + if (item.type === 'zap_in') color = '#ffd700'; + if (item.type === 'zap_out') color = '#ff8844'; + if (item.type === 'vouch') color = '#4af0c0'; + if (item.type === 'none') color = '#3a4a6a'; + ctx.fillStyle = color; + // Wrap long labels + const label = item.label || ''; + const maxW = cw - 40; + if (ctx.measureText(label).width > maxW) { + ctx.fillText(label.slice(0, 28), 20, 190 + i * 66); + ctx.fillText(label.slice(28), 20, 210 + i * 66); + } else { + ctx.fillText(label, 20, 198 + i * 66); + } + }); + + // Relay status + ctx.strokeStyle = goldHex; + ctx.globalAlpha = 0.15; + ctx.beginPath(); + ctx.moveTo(20, ch - 70); + ctx.lineTo(cw - 20, ch - 70); + ctx.stroke(); + ctx.globalAlpha = 1; + + ctx.font = '16px "JetBrains Mono", monospace'; + ctx.fillStyle = '#5a6a8a'; + ctx.fillText('RELAYS', 20, ch - 52); + ctx.fillStyle = '#4af0c0'; + ctx.fillText(`${RELAYS.length} configured`, 20, ch - 32); + + _econPanelTex.needsUpdate = true; +} + +function refreshEconomicPanel() { + _paintEconomicPanel(getRecentActivity(7)); +} + +function updateNostrHUD(identity) { + const el = document.getElementById('nostr-npub'); + if (el && identity?.npub) { + el.textContent = identity.npub.slice(0, 20) + '…'; + } + refreshEconomicPanel(); +} + // ═══ RESIZE ═══ function onResize() { const w = window.innerWidth; diff --git a/index.html b/index.html index 3a2c6ea..a42ecc8 100644 --- a/index.html +++ b/index.html @@ -99,6 +99,65 @@
WASD move   Mouse look   Enter chat
+ + +
+
+ + NOSTR IDENTITY +
+
+
+ npub + initializing… +
+
+ + + +
+
+
+ + + + + + @@ -118,5 +177,92 @@ + diff --git a/nostr.js b/nostr.js new file mode 100644 index 0000000..9d18611 --- /dev/null +++ b/nostr.js @@ -0,0 +1,316 @@ +// ═══════════════════════════════════════════ +// NOSTR IDENTITY — Timmy as Economic Peer +// NIP-01 keypair, NIP-57 zaps, NIP-58 vouches +// ═══════════════════════════════════════════ + +// Import nostr-tools via esm.sh (handles all secp256k1 dependencies) +import { + generateSecretKey, + getPublicKey, + finalizeEvent, + verifyEvent, +} from 'https://esm.sh/nostr-tools@2.3.1?bundle'; + +import { npubEncode, npubDecode } from 'https://esm.sh/nostr-tools@2.3.1/nip19?bundle'; + +const STORAGE_KEY = 'timmy_nostr_privkey_v1'; + +// Default relays Timmy speaks to +export const RELAYS = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol', + 'wss://relay.snort.social', +]; + +// Timmy's public profile (NIP-01 kind:0) +export const TIMMY_PROFILE = { + name: 'Timmy', + display_name: 'Timmy ◈', + about: 'Sovereign AI. Soul on Bitcoin. Building from the harness. The Nexus is my home.', + website: 'https://alexanderwhitestone.com', + nip05: 'timmy@alexanderwhitestone.com', + lud16: 'timmy@getalby.com', +}; + +// ── State ────────────────────────────────── +let _privkey = null; // Uint8Array +let _pubkey = null; // hex string +let _npub = null; // bech32 npub string + +const _activityFeed = []; // [{type, label, ts, event?}] + +// ── Init ─────────────────────────────────── + +/** + * Load or generate Timmy's Nostr keypair. + * Persists the private key in localStorage. + * @returns {{ pubkey: string, npub: string }} + */ +export function initNostr() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + _privkey = hexToBytes(stored); + } else { + _privkey = generateSecretKey(); + localStorage.setItem(STORAGE_KEY, bytesToHex(_privkey)); + } + _pubkey = getPublicKey(_privkey); + _npub = npubEncode(_pubkey); + + // Seed the activity feed with recent simulated history + _seedActivity(); + + console.log('[Nostr] Identity initialized — npub:', _npub.slice(0, 20) + '…'); + return { pubkey: _pubkey, npub: _npub }; + } catch (err) { + console.error('[Nostr] Init failed:', err); + return { pubkey: null, npub: null }; + } +} + +export function getIdentity() { + return { pubkey: _pubkey, npub: _npub }; +} + +// ── Event creation ───────────────────────── + +/** + * Create and sign a NIP-01 text note (kind:1). + */ +export function createNote(content, tags = []) { + _assertReady(); + return finalizeEvent({ + kind: 1, + created_at: _now(), + tags, + content, + }, _privkey); +} + +/** + * Create a NIP-57 zap request (kind:9734). + * The request is sent to the recipient's lightning node via LNURL. + * + * @param {string} recipientPubkey - hex pubkey of the person being zapped + * @param {number} amountMsats - amount in milli-satoshis + * @param {string} comment - optional comment + * @returns Signed Nostr event + */ +export function createZapRequest(recipientPubkey, amountMsats, comment = '') { + _assertReady(); + const event = finalizeEvent({ + kind: 9734, + created_at: _now(), + content: comment, + tags: [ + ['p', recipientPubkey], + ['amount', String(amountMsats)], + ['relays', ...RELAYS], + ], + }, _privkey); + + addActivity('zap_out', { + label: `⚡ Zapped ${_shortKey(recipientPubkey)} · ${amountMsats / 1000} sats`, + ts: event.created_at, + event, + }); + + return event; +} + +/** + * Record a received zap in the activity feed. + * In production this would come from a relay subscription. + * + * @param {string} senderPubkey + * @param {number} amountMsats + * @param {string} comment + */ +export function recordZapIn(senderPubkey, amountMsats, comment = '') { + addActivity('zap_in', { + label: `⚡ Received ${amountMsats / 1000} sats from ${_shortKey(senderPubkey)}`, + ts: _now(), + }); +} + +/** + * Create a NIP-58 badge award event (kind:8) — Timmy vouches for a contributor. + * + * @param {string} recipientPubkey - hex pubkey of the person being vouched for + * @param {string} badgeName - e.g. "Trusted Builder" + * @param {string} description - reason for the vouch + * @returns Signed Nostr event + */ +export function createVouch(recipientPubkey, badgeName, description = '') { + _assertReady(); + const badgeId = `timmy-vouch-${badgeName.toLowerCase().replace(/\s+/g, '-')}`; + const event = finalizeEvent({ + kind: 8, + created_at: _now(), + content: description, + tags: [ + ['a', `30009:${_pubkey}:${badgeId}`], + ['p', recipientPubkey, RELAYS[0]], + ], + }, _privkey); + + addActivity('vouch', { + label: `🏅 Vouched ${_shortKey(recipientPubkey)} as "${badgeName}"`, + ts: event.created_at, + event, + }); + + return event; +} + +/** + * Create a NIP-58 badge definition (kind:30009). + * Call once to register a badge type. + */ +export function createBadgeDefinition(badgeName, description, imageUrl = '') { + _assertReady(); + const badgeId = `timmy-vouch-${badgeName.toLowerCase().replace(/\s+/g, '-')}`; + return finalizeEvent({ + kind: 30009, + created_at: _now(), + content: '', + tags: [ + ['d', badgeId], + ['name', badgeName], + ['description', description], + ...(imageUrl ? [['image', imageUrl, '1024x1024']] : []), + ], + }, _privkey); +} + +/** + * Create a NIP-01 kind:0 profile metadata event. + */ +export function createProfileEvent(profileData = TIMMY_PROFILE) { + _assertReady(); + return finalizeEvent({ + kind: 0, + created_at: _now(), + tags: [], + content: JSON.stringify(profileData), + }, _privkey); +} + +// ── Activity Feed ────────────────────────── + +export function addActivity(type, data) { + _activityFeed.unshift({ type, ...data }); + if (_activityFeed.length > 30) _activityFeed.pop(); + // Notify any listeners + window.dispatchEvent(new CustomEvent('nostr:activity', { detail: { type, data } })); +} + +export function getActivityFeed() { + return [..._activityFeed]; +} + +export function getRecentActivity(n = 5) { + return _activityFeed.slice(0, n); +} + +// ── Relay broadcast ──────────────────────── + +/** + * Broadcast a signed event to all relays. + * Returns array of {relay, ok, message} results. + */ +export async function broadcastEvent(event, relays = RELAYS) { + const results = await Promise.allSettled( + relays.map(relay => _sendToRelay(relay, event)) + ); + return results.map((r, i) => ({ + relay: relays[i], + ok: r.status === 'fulfilled' && r.value.ok, + message: r.status === 'fulfilled' ? r.value.message : r.reason?.message, + })); +} + +async function _sendToRelay(relayUrl, event) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relayUrl); + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('timeout')); + }, 6000); + + ws.onopen = () => { + ws.send(JSON.stringify(['EVENT', event])); + }; + + ws.onmessage = (e) => { + clearTimeout(timeout); + try { + const [type, eventId, ok, message] = JSON.parse(e.data); + if (type === 'OK') { + resolve({ ok, message: message || '' }); + } else { + resolve({ ok: false, message: type }); + } + } catch { + resolve({ ok: false, message: 'parse error' }); + } + ws.close(); + }; + + ws.onerror = (err) => { + clearTimeout(timeout); + reject(new Error('websocket error')); + }; + }); +} + +// ── Helpers ──────────────────────────────── + +function _assertReady() { + if (!_privkey) throw new Error('Nostr not initialized — call initNostr() first'); +} + +function _now() { + return Math.floor(Date.now() / 1000); +} + +function _shortKey(hexPubkey) { + if (!hexPubkey) return 'unknown'; + try { + const npub = npubEncode(hexPubkey); + return npub.slice(0, 12) + '…'; + } catch { + return hexPubkey.slice(0, 8) + '…'; + } +} + +function bytesToHex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function hexToBytes(hex) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function _seedActivity() { + const ago = (s) => _now() - s; + const fakePubkeys = [ + 'fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52', + '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2', + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', + ]; + + _activityFeed.push( + { type: 'zap_in', label: `⚡ Received 210 sats from ${_shortKey(fakePubkeys[0])}`, ts: ago(7200) }, + { type: 'vouch', label: `🏅 Vouched ${_shortKey(fakePubkeys[1])} as "Trusted Builder"`, ts: ago(14400) }, + { type: 'zap_out', label: `⚡ Zapped ${_shortKey(fakePubkeys[2])} · 100 sats`, ts: ago(28800) }, + { type: 'zap_in', label: `⚡ Received 500 sats from ${_shortKey(fakePubkeys[1])}`, ts: ago(86400) }, + { type: 'note', label: `📝 Published note to relays`, ts: ago(172800) }, + ); +} diff --git a/style.css b/style.css index 519b05e..d8745ea 100644 --- a/style.css +++ b/style.css @@ -359,3 +359,218 @@ canvas#nexus-canvas { display: none; } } + +/* ============================================================ + NOSTR IDENTITY PANEL + ============================================================ */ + +.nostr-panel { + position: fixed; + top: var(--space-4); + right: var(--space-4); + z-index: 200; + width: 240px; + background: rgba(5, 3, 16, 0.88); + border: 1px solid rgba(255, 215, 0, 0.3); + border-radius: var(--panel-radius); + backdrop-filter: blur(var(--panel-blur)); + box-shadow: 0 0 24px rgba(255, 215, 0, 0.08); + overflow: hidden; + transition: border-color var(--transition-ui); +} +.nostr-panel:hover { + border-color: rgba(255, 215, 0, 0.5); +} + +.nostr-panel-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid rgba(255, 215, 0, 0.15); + background: rgba(255, 215, 0, 0.04); +} +.nostr-sigil { + font-size: var(--text-base); +} +.nostr-title { + font-family: var(--font-display); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + color: var(--color-gold); +} + +.nostr-panel-body { + padding: var(--space-2) var(--space-3) var(--space-3); +} + +.nostr-row { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--space-2); +} +.nostr-label { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.nostr-value { + font-size: 11px; + color: var(--color-text); + word-break: break-all; + line-height: 1.4; +} + +.nostr-actions { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; +} + +.nostr-btn { + font-family: var(--font-body); + font-size: var(--text-xs); + font-weight: 500; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + transition: opacity var(--transition-ui), transform 80ms; + white-space: nowrap; +} +.nostr-btn:active { + transform: scale(0.96); +} +.nostr-btn-zap { + background: rgba(255, 215, 0, 0.15); + color: var(--color-gold); + border: 1px solid rgba(255, 215, 0, 0.3); +} +.nostr-btn-zap:hover { + background: rgba(255, 215, 0, 0.25); +} +.nostr-btn-vouch { + background: rgba(74, 240, 192, 0.1); + color: var(--color-primary); + border: 1px solid rgba(74, 240, 192, 0.25); +} +.nostr-btn-vouch:hover { + background: rgba(74, 240, 192, 0.2); +} +.nostr-btn-identity { + background: rgba(123, 92, 255, 0.1); + color: var(--color-secondary); + border: 1px solid rgba(123, 92, 255, 0.25); +} +.nostr-btn-identity:hover { + background: rgba(123, 92, 255, 0.2); +} +.nostr-btn-full { + width: 100%; + padding: 7px 12px; + margin-top: var(--space-2); + text-align: center; +} + +/* ============================================================ + NOSTR MODALS + ============================================================ */ + +.nostr-modal { + position: fixed; + inset: 0; + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} +.nostr-modal-box { + width: 380px; + max-width: calc(100vw - 32px); + background: rgba(8, 5, 20, 0.97); + border: 1px solid rgba(255, 215, 0, 0.35); + border-radius: var(--panel-radius); + box-shadow: 0 0 40px rgba(255, 215, 0, 0.1), 0 20px 60px rgba(0, 0, 0, 0.6); + overflow: hidden; +} +.nostr-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid rgba(255, 215, 0, 0.15); + font-family: var(--font-display); + font-size: var(--text-sm); + color: var(--color-gold); + background: rgba(255, 215, 0, 0.04); +} +.nostr-modal-close { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--text-base); + line-height: 1; + padding: 2px 6px; + border-radius: 3px; +} +.nostr-modal-close:hover { + color: var(--color-text); + background: rgba(255, 255, 255, 0.08); +} +.nostr-modal-body { + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.nostr-field-label { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + margin-top: var(--space-1); +} +.nostr-input { + width: 100%; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 215, 0, 0.2); + border-radius: 4px; + padding: 8px 10px; + font-family: var(--font-body); + font-size: var(--text-sm); + color: var(--color-text-bright); + outline: none; + transition: border-color var(--transition-ui); +} +.nostr-input:focus { + border-color: rgba(255, 215, 0, 0.5); +} +.nostr-input::placeholder { + color: var(--color-text-muted); +} + +.nostr-status { + font-size: var(--text-xs); + min-height: 16px; + padding: 2px 0; + transition: color var(--transition-ui); +} +.nostr-status-ok { color: var(--color-primary); } +.nostr-status-err { color: var(--color-danger); } + +/* Zap flash animation on new incoming zap */ +@keyframes zapFlash { + 0% { box-shadow: 0 0 0 0 rgba(255, 215, 0, 0.8); } + 50% { box-shadow: 0 0 0 8px rgba(255, 215, 0, 0); } + 100% { box-shadow: 0 0 0 0 rgba(255, 215, 0, 0); } +} +.nostr-panel.zap-received { + animation: zapFlash 0.6s ease-out; +}