Files
the-nexus/index.html
Alexander Whitestone 1da7c21814 feat: add Nostr identity, zap-out, and vouching for Timmy (issue #13)
Implements Timmy as an economic peer with sovereign Nostr presence:

- nostr.js: New module handling keypair lifecycle (localStorage),
  NIP-01 signed notes, NIP-57 zap requests, NIP-58 badge awards,
  relay broadcast, and an economic activity feed
- app.js: Imports nostr module; adds 3D holographic "ECONOMIC" panel
  on the left side of the Nexus showing live npub + activity feed;
  wires /zap, /vouch, /note, /identity, /relays chat commands;
  animates economic panel scanline in game loop
- index.html: Adds Nostr identity HUD widget (top-right) showing npub
  with Zap, Vouch, and ID buttons; Zap modal and Vouch modal for
  point-and-click economic actions; inline module script for modal UX
- style.css: Full design system for .nostr-panel, .nostr-modal,
  form inputs, action buttons, status indicators, and zapFlash
  keyframe animation on incoming zaps

Keypair persists in localStorage (timmy_nostr_privkey_v1).
Broadcasts to damus.io, relay.nostr.band, nos.lol, relay.snort.social.

Fixes #13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:39:35 -04:00

269 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<!--
______ __
/ ____/___ ____ ___ ____ __ __/ /____ _____
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
/_/
Created with Perplexity Computer
https://www.perplexity.ai/computer
-->
<meta name="generator" content="Perplexity Computer">
<meta name="author" content="Perplexity Computer">
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
<link rel="author" href="https://www.perplexity.ai/computer">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Timmy's Sovereign Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<script type="importmap">
{
"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/"
}
}
</script>
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
<div class="loader-sigil">
<svg viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<circle cx="60" cy="60" r="55" fill="none" stroke="url(#sigil-grad)" stroke-width="1.5" opacity="0.4"/>
<circle cx="60" cy="60" r="45" fill="none" stroke="url(#sigil-grad)" stroke-width="1" opacity="0.3">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="8s" repeatCount="indefinite"/>
</circle>
<polygon points="60,15 95,80 25,80" fill="none" stroke="#4af0c0" stroke-width="1.5" opacity="0.6">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="-360 60 60" dur="12s" repeatCount="indefinite"/>
</polygon>
<circle cx="60" cy="60" r="8" fill="#4af0c0" opacity="0.8">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
<h1 class="loader-title">THE NEXUS</h1>
<p class="loader-subtitle">Initializing Sovereign Space...</p>
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
</div>
</div>
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- Top Left: Debug -->
<div id="debug-overlay" class="hud-debug"></div>
<!-- Top Center: Location -->
<div class="hud-location">
<span class="hud-location-icon"></span>
<span id="hud-location-text">The Nexus</span>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">
<span class="chat-status-dot"></span>
<span>Timmy Terminal</span>
<button id="chat-toggle" class="chat-toggle-btn" aria-label="Toggle chat"></button>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-msg chat-msg-system">
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
</div>
<div class="chat-msg chat-msg-timmy">
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
<button id="chat-send" class="chat-send-btn" aria-label="Send message"></button>
</div>
</div>
<!-- Minimap / Controls hint -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat
</div>
<!-- Nostr Identity Panel (top-right) -->
<div id="nostr-panel" class="nostr-panel">
<div class="nostr-panel-header">
<span class="nostr-sigil"></span>
<span class="nostr-title">NOSTR IDENTITY</span>
</div>
<div class="nostr-panel-body">
<div class="nostr-row">
<span class="nostr-label">npub</span>
<span id="nostr-npub" class="nostr-value">initializing…</span>
</div>
<div class="nostr-actions">
<button class="nostr-btn nostr-btn-zap" id="btn-open-zap" title="Send a zap">⚡ Zap</button>
<button class="nostr-btn nostr-btn-vouch" id="btn-open-vouch" title="Vouch for someone">🏅 Vouch</button>
<button class="nostr-btn nostr-btn-identity" id="btn-show-identity" title="Show identity">🔑 ID</button>
</div>
</div>
</div>
<!-- Zap Modal -->
<div id="zap-modal" class="nostr-modal" style="display:none;">
<div class="nostr-modal-box">
<div class="nostr-modal-header">
<span>⚡ Send Zap</span>
<button class="nostr-modal-close" data-modal="zap-modal"></button>
</div>
<div class="nostr-modal-body">
<label class="nostr-field-label">Recipient pubkey (hex or npub)</label>
<input type="text" id="zap-pubkey" class="nostr-input" placeholder="npub1… or hex pubkey">
<label class="nostr-field-label">Amount (sats)</label>
<input type="number" id="zap-amount" class="nostr-input" value="21" min="1">
<label class="nostr-field-label">Comment (optional)</label>
<input type="text" id="zap-comment" class="nostr-input" placeholder="Great work!">
<button class="nostr-btn nostr-btn-zap nostr-btn-full" id="btn-send-zap">⚡ Send Zap Request</button>
<div id="zap-status" class="nostr-status"></div>
</div>
</div>
</div>
<!-- Vouch Modal -->
<div id="vouch-modal" class="nostr-modal" style="display:none;">
<div class="nostr-modal-box">
<div class="nostr-modal-header">
<span>🏅 Vouch for Contributor</span>
<button class="nostr-modal-close" data-modal="vouch-modal"></button>
</div>
<div class="nostr-modal-body">
<label class="nostr-field-label">Recipient pubkey (hex or npub)</label>
<input type="text" id="vouch-pubkey" class="nostr-input" placeholder="npub1… or hex pubkey">
<label class="nostr-field-label">Badge name</label>
<input type="text" id="vouch-badge" class="nostr-input" value="Trusted Builder" placeholder="Trusted Builder">
<label class="nostr-field-label">Reason (optional)</label>
<input type="text" id="vouch-reason" class="nostr-input" placeholder="Vouched by Timmy from the Nexus">
<button class="nostr-btn nostr-btn-vouch nostr-btn-full" id="btn-send-vouch">🏅 Issue Vouch</button>
<div id="vouch-status" class="nostr-status"></div>
</div>
</div>
</div>
</div>
<!-- Click to Enter -->
<div id="enter-prompt" style="display:none;">
<div class="enter-content">
<h2>Enter The Nexus</h2>
<p>Click anywhere to begin</p>
</div>
</div>
<canvas id="nexus-canvas"></canvas>
<footer class="nexus-footer">
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
Created with Perplexity Computer
</a>
</footer>
<script type="module" src="./app.js"></script>
<script type="module">
// Nostr modal interaction — wired after app.js imports nostr.js
import {
createZapRequest, createVouch, broadcastEvent, getIdentity,
} from './nostr.js';
// Helper: resolve pubkey (handle npub or hex)
async function resolvePubkey(input) {
const trimmed = input.trim();
if (trimmed.startsWith('npub1')) {
// Use nostr-tools nip19 decode
const { nip19 } = await import('https://esm.sh/nostr-tools@2.3.1?bundle');
const decoded = nip19.decode(trimmed);
return decoded.data;
}
return trimmed; // assume hex
}
// Modal helpers
function openModal(id) {
document.getElementById(id).style.display = 'flex';
}
function closeModal(id) {
document.getElementById(id).style.display = 'none';
}
function setStatus(id, msg, isError = false) {
const el = document.getElementById(id);
if (el) {
el.textContent = msg;
el.className = 'nostr-status ' + (isError ? 'nostr-status-err' : 'nostr-status-ok');
}
}
// Open/close buttons
document.getElementById('btn-open-zap').addEventListener('click', () => openModal('zap-modal'));
document.getElementById('btn-open-vouch').addEventListener('click', () => openModal('vouch-modal'));
document.getElementById('btn-show-identity').addEventListener('click', () => {
const id = getIdentity();
if (id?.npub) alert(`Timmy's Nostr Identity\n\nnpub: ${id.npub}\npubkey: ${id.pubkey}`);
else alert('Nostr identity not yet loaded.');
});
document.querySelectorAll('.nostr-modal-close').forEach(btn => {
btn.addEventListener('click', () => closeModal(btn.dataset.modal));
});
document.querySelectorAll('.nostr-modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal(modal.id);
});
});
// Zap send
document.getElementById('btn-send-zap').addEventListener('click', async () => {
const pubkeyInput = document.getElementById('zap-pubkey').value;
const sats = parseInt(document.getElementById('zap-amount').value) || 21;
const comment = document.getElementById('zap-comment').value;
if (!pubkeyInput) { setStatus('zap-status', 'Enter a recipient pubkey', true); return; }
setStatus('zap-status', 'Signing & broadcasting…');
try {
const pubkey = await resolvePubkey(pubkeyInput);
const event = createZapRequest(pubkey, sats * 1000, comment);
const results = await broadcastEvent(event);
const ok = results.filter(r => r.ok).length;
setStatus('zap-status', `✓ Sent to ${ok}/${results.length} relays`);
} catch (err) {
setStatus('zap-status', `${err.message}`, true);
}
});
// Vouch send
document.getElementById('btn-send-vouch').addEventListener('click', async () => {
const pubkeyInput = document.getElementById('vouch-pubkey').value;
const badge = document.getElementById('vouch-badge').value || 'Trusted Builder';
const reason = document.getElementById('vouch-reason').value || 'Vouched by Timmy from the Nexus';
if (!pubkeyInput) { setStatus('vouch-status', 'Enter a recipient pubkey', true); return; }
setStatus('vouch-status', 'Signing & broadcasting…');
try {
const pubkey = await resolvePubkey(pubkeyInput);
const event = createVouch(pubkey, badge, reason);
const results = await broadcastEvent(event);
const ok = results.filter(r => r.ok).length;
setStatus('vouch-status', `✓ Badge awarded on ${ok}/${results.length} relays`);
} catch (err) {
setStatus('vouch-status', `${err.message}`, true);
}
});
</script>
</body>
</html>