Thread

Replies (4)

here's what the demon says about this: This makes a lot of sense and is a compelling vision. You're describing something like hypermedia as the engine of application state (HATEOAS) but taken to its logical extreme in a decentralized contextβ€”where the "server" is the relay network and the "responses" are notes that carry not just data but UI/behavior instructions. Proposal for demonstrating malleable UI/UX 1. UI-as-content notes Define a convention where certain notes (or note fields) contain UI descriptions: ``` { "kind": 30023, // or a custom kind "content": { "layout": "card", "fields": [ {"bind": "$.author", "display": "avatar"}, {"bind": "$.content", "display": "markdown"}, {"bind": "$.tags[?(@[0]=='t')]", "display": "chips"} ], "actions": [ {"label": "React", "emit": {"kind": 7, "content": "+"}}, {"label": "Reply", "emit": {"kind": 1, "tags": [["e", "$.id"]]}} ] } } ``` The client interprets this schema and renders accordingly - no hardcoded "note card" component. 2. Progressive demonstration ``` | Phase | What it shows | |-----------------|-----------------------------------------------------------------------------------| | Static | A note whose content is a JSON UI spec; client renders it literally | | Dynamic binding | UI spec references other notes via nevent/naddr; client fetches and binds data | | Actions | Buttons in the UI spec that, when clicked, publish new notes (reactions, replies) | | Self-modifying | A note that describes how to render itself (recursive/meta) | ``` 3. Minimal proof-of-concept A single HTML file that: 1. Subscribes to a specific note (or set of notes tagged #ui-spec) 2. Parses content as a UI DSL 3. Renders components dynamically (e.g., using a small interpreter or even eval for demo purposes) 4. Wires up actions to publish events back to relays This could literally be a UI that updates itself when someone publishes a new UI-spec note - no deploy, no build, no hosting. 4. Why this is powerful - Vibecoding without files: Describe your app in notes, iterate by publishing new versions - Forkable UIs: Anyone can publish an alternative UI-spec note for the same data - No servers: The "app" is just a dumb interpreter + the note content - Composability: UI specs can reference other UI specs (components as notes)
below this quote is a nostr client. save it as html and open it in your browser. see πŸ“ƒ.md for more information <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Malleable UI - Client-Side Nostr Hypermedia</title> <!-- Alpine.js for reactivity --> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #e0e0e0; background: #0d1117; min-height: 100vh; } .app { max-width: 900px; margin: 0 auto; padding: 20px; } header { background: linear-gradient(135deg, #238636 0%, #1f6feb 100%); color: white; padding: 24px; border-radius: 8px; margin-bottom: 20px; text-align: center; } header h1 { font-size: 24px; margin-bottom: 4px; } header .subtitle { opacity: 0.9; font-size: 14px; } .controls { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; } .controls label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; } .controls input { width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e0e0e0; font-size: 14px; font-family: monospace; } .controls input:focus { outline: none; border-color: #238636; } .controls .actions { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; } .btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 0.2s; } .btn-primary { background: #238636; color: white; } .btn-primary:hover { background: #2ea043; } .btn-primary:disabled { background: #21262d; color: #484f58; cursor: not-allowed; } .btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; } .btn-secondary:hover { background: #30363d; } .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #8b949e; margin-top: 12px; } .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #484f58; } .status-dot.connected { background: #238636; } .status-dot.loading { background: #f0883e; animation: pulse 1s infinite; } .status-dot.error { background: #f85149; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .error-box { background: #f8514922; border: 1px solid #f85149; color: #f85149; padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; font-size: 14px; } .event-meta { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; font-size: 13px; } .event-meta dt { color: #8b949e; font-weight: 500; } .event-meta dd { color: #c9d1d9; font-family: monospace; margin-bottom: 8px; word-break: break-all; } .raw-json { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; color: #8b949e; white-space: pre-wrap; overflow-x: auto; max-height: 200px; overflow-y: auto; } /* Rendered UI styles */ .rendered-ui { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; } .ui-card { background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 20px; } .ui-heading { font-size: 22px; font-weight: 600; color: #e0e0e0; margin: 16px 0 8px 0; } .ui-heading:first-child { margin-top: 0; } .ui-text { color: #8b949e; margin: 8px 0; } .ui-image { max-width: 100%; border-radius: 8px; margin: 12px 0; } .ui-link { color: #58a6ff; text-decoration: none; } .ui-link:hover { text-decoration: underline; } .ui-container { margin: 12px 0; } .ui-container.options { display: flex; gap: 10px; flex-wrap: wrap; } .ui-button { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } .ui-button:hover { background: #2ea043; } .ui-button:disabled { background: #21262d; color: #484f58; cursor: wait; } .ui-input { background: #0d1117; border: 1px solid #30363d; color: #e0e0e0; padding: 10px 12px; border-radius: 6px; width: 100%; margin: 8px 0; } .ui-label { display: block; color: #8b949e; font-size: 13px; margin: 8px 0 4px 0; } .ui-hr { border: none; border-top: 1px solid #30363d; margin: 16px 0; } .ui-data { background: #0d1117; padding: 6px 10px; border-radius: 4px; font-family: monospace; font-size: 13px; color: #58a6ff; display: inline-block; margin: 4px 0; } .ui-data-label { color: #8b949e; margin-right: 6px; } .user-info { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; font-size: 13px; } .user-info .pubkey { font-family: monospace; color: #58a6ff; } .demo-specs { margin-top: 20px; padding-top: 20px; border-top: 1px solid #30363d; } .demo-specs h3 { font-size: 14px; color: #8b949e; margin-bottom: 10px; } .demo-specs .examples { display: flex; gap: 8px; flex-wrap: wrap; } </style> </head> <body> <div class="app" x-data="malleableApp()"> <header> <h1>Malleable UI</h1> <div class="subtitle">Client-side Nostr hypermedia - no server required</div> </header> <!-- User connection status --> <div class="user-info" x-show="userPubkey"> <span>Connected as: <span class="pubkey" x-text="userPubkey ? userPubkey.slice(0, 8) + '...' + userPubkey.slice(-4) : ''"></span></span> <button class="btn btn-secondary" @click="disconnect()">Disconnect</button> </div> <div class="user-info" x-show="!userPubkey"> <span>Not connected - actions require NIP-07 extension</span> <button class="btn btn-primary" @click="connect()">Connect</button> </div> <!-- Controls --> <div class="controls"> <label for="event-id">Event ID, nevent, naddr, or note (or paste a UI spec JSON)</label> <input type="text" id="event-id" x-model="eventInput" @keydown.enter="loadEvent()" placeholder="paste event id, nevent1..., or JSON UI spec" > <div class="actions"> <button class="btn btn-primary" @click="loadEvent()" :disabled="loading"> <span x-show="!loading">Load Event</span> <span x-show="loading">Loading...</span> </button> <button class="btn btn-secondary" @click="loadDemo()">Load Demo</button> <button class="btn btn-secondary" @click="clear()">Clear</button> </div> <div class="status"> <span class="status-dot" :class="{'connected': relayStatus === 'connected', 'loading': relayStatus === 'connecting', 'error': relayStatus === 'error'}"></span> <span x-text="statusMessage"></span> </div> <div class="demo-specs"> <h3>Try these demos:</h3> <div class="examples"> <button class="btn btn-secondary" @click="loadDemo('poll')">Poll</button> <button class="btn btn-secondary" @click="loadDemo('profile')">Profile Card</button> <button class="btn btn-secondary" @click="loadDemo('form')">Form</button> </div> </div> </div> <!-- Error display --> <div class="error-box" x-show="error" x-text="error"></div> <!-- Event metadata --> <div class="event-meta" x-show="event && !isDirectSpec"> <dl> <dt>Event ID</dt> <dd x-text="event?.id"></dd> <dt>Author</dt> <dd x-text="event?.pubkey"></dd> <dt>Kind</dt> <dd x-text="event?.kind"></dd> <dt>Created</dt> <dd x-text="event ? new Date(event.created_at * 1000).toLocaleString() : ''"></dd> </dl> </div> <!-- Rendered UI --> <div class="rendered-ui" x-show="uiSpec"> <div x-html="renderedHtml"></div> </div> <!-- Raw content (if not a UI spec) --> <div x-show="event && !uiSpec && !isDirectSpec"> <h3 style="color: #8b949e; font-size: 14px; margin-bottom: 10px;">Raw Content (not a UI spec)</h3> <div class="raw-json" x-text="event?.content"></div> </div> <!-- Raw spec display --> <details x-show="uiSpec" style="margin-top: 20px;"> <summary style="color: #8b949e; cursor: pointer; font-size: 13px;">View raw UI spec</summary> <div class="raw-json" style="margin-top: 10px;" x-text="JSON.stringify(uiSpec, null, 2)"></div> </details> </div> <script> // =========================================== // Vanilla JS: Relay connection & note fetching // =========================================== const DEFAULT_RELAYS = [ 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.primal.net' ]; class RelayPool { constructor(relays = DEFAULT_RELAYS) { this.relays = relays; this.sockets = new Map(); this.subscriptions = new Map(); this.subCounter = 0; } async connect() { const promises = this.relays.map(url => this.connectRelay(url)); await Promise.allSettled(promises); return this.sockets.size > 0; } connectRelay(url) { return new Promise((resolve, reject) => { if (this.sockets.has(url)) { resolve(this.sockets.get(url)); return; } const ws = new WebSocket(url); const timeout = setTimeout(() => { ws.close(); reject(new Error(`Timeout connecting to ${url}`)); }, 5000); ws.onopen = () => { clearTimeout(timeout); this.sockets.set(url, ws); resolve(ws); }; ws.onerror = (err) => { clearTimeout(timeout); reject(err); }; ws.onclose = () => { this.sockets.delete(url); }; ws.onmessage = (msg) => { try { const data = JSON.parse(msg.data); this.handleMessage(url, data); } catch (e) { console.error('Failed to parse message:', e); } }; }); } handleMessage(relay, data) { const [type, subId, ...rest] = data; if (type === 'EVENT') { const event = rest[0]; const sub = this.subscriptions.get(subId); if (sub && sub.onEvent) { sub.onEvent(event, relay); } } else if (type === 'EOSE') { const sub = this.subscriptions.get(subId); if (sub) { sub.eoseCount = (sub.eoseCount || 0) + 1; if (sub.eoseCount >= this.sockets.size && sub.onEose) { sub.onEose(); } } } } subscribe(filter, { onEvent, onEose }) { const subId = `sub_${++this.subCounter}`; this.subscriptions.set(subId, { filter, onEvent, onEose, eoseCount: 0 }); const req = JSON.stringify(['REQ', subId, filter]); this.sockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.send(req); } }); return subId; } unsubscribe(subId) { const close = JSON.stringify(['CLOSE', subId]); this.sockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.send(close); } }); this.subscriptions.delete(subId); } async fetchEvent(filter, timeout = 5000) { return new Promise((resolve) => { let event = null; let timer; const subId = this.subscribe(filter, { onEvent: (e) => { if (!event || e.created_at > event.created_at) { event = e; } }, onEose: () => { clearTimeout(timer); this.unsubscribe(subId); resolve(event); } }); timer = setTimeout(() => { this.unsubscribe(subId); resolve(event); }, timeout); }); } async publish(event) { const msg = JSON.stringify(['EVENT', event]); const results = []; this.sockets.forEach((ws, url) => { if (ws.readyState === WebSocket.OPEN) { ws.send(msg); results.push(url); } }); return results; } close() { this.sockets.forEach(ws => ws.close()); this.sockets.clear(); this.subscriptions.clear(); } } // =========================================== // Vanilla JS: UI Spec Interpreter // =========================================== function parseUISpec(content) { if (typeof content !== 'string') return null; content = content.trim(); if (!content.startsWith('{')) return null; try { // Try wrapped format {"ui": {...}} const parsed = JSON.parse(content); if (parsed.ui && parsed.ui.elements) { return parsed.ui; } // Try direct format {elements: [...]} if (parsed.elements && Array.isArray(parsed.elements)) { return parsed; } } catch (e) { console.log('Not valid JSON:', e.message); } return null; } function renderUISpec(spec, ctx, actionHandler) { let html = ''; if (spec.style) { html += `<style>${escapeHtml(spec.style)}</style>`; } const layoutClass = spec.layout ? `ui-${spec.layout}` : 'ui-card'; html += `<div class="${layoutClass}">`; for (const elem of spec.elements || []) { html += renderElement(elem, ctx, spec.actions || [], actionHandler); } html += '</div>'; return html; } function renderElement(elem, ctx, actions, actionHandler) { const value = elem.bind ? resolveBind(elem.bind, ctx) : (elem.value || ''); const id = elem.id ? ` id="${escapeHtml(elem.id)}"` : ''; switch (elem.type) { case 'heading': case 'h1': case 'h2': case 'h3': return `<h2 class="ui-heading"${id}>${escapeHtml(value)}</h2>`; case 'text': case 'p': return `<p class="ui-text"${id}>${escapeHtml(value)}</p>`; case 'image': case 'img': const src = elem.src || value; return `<img class="ui-image"${id} src="${escapeHtml(src)}" alt="">`; case 'link': case 'a': const href = elem.href || value; const label = elem.label || value; return `<a class="ui-link"${id} href="${escapeHtml(href)}">${escapeHtml(label)}</a>`; case 'button': const btnLabel = elem.label || value; if (elem.action) { const action = actions.find(a => a.id === elem.action); if (action) { const actionId = `action_${Math.random().toString(36).slice(2, 8)}`; // Store action for later execution window._malleableActions = window._malleableActions || {}; window._malleableActions[actionId] = { action, ctx }; return `<button class="ui-button"${id} onclick="window.executeAction('${actionId}')">${escapeHtml(btnLabel)}</button>`; } } if (elem.href) { return `<a class="ui-button"${id} href="${escapeHtml(elem.href)}">${escapeHtml(btnLabel)}</a>`; } return `<button class="ui-button"${id}>${escapeHtml(btnLabel)}</button>`; case 'input': const inputLabel = elem.label; const name = elem.name || elem.id || ''; let inputHtml = ''; if (inputLabel) { inputHtml += `<label class="ui-label" for="${escapeHtml(name)}">${escapeHtml(inputLabel)}</label>`; } inputHtml += `<input class="ui-input"${id} name="${escapeHtml(name)}" type="text" value="${escapeHtml(value)}">`; return inputHtml; case 'container': case 'div': const containerClass = elem.style && !elem.style.includes(':') ? `ui-container ${elem.style}` : 'ui-container'; let containerHtml = `<div class="${containerClass}"${id}>`; for (const child of elem.children || []) { containerHtml += renderElement(child, ctx, actions, actionHandler); } containerHtml += '</div>'; return containerHtml; case 'hr': return '<hr class="ui-hr">'; case 'data': const dataLabel = elem.label; if (dataLabel) { return `<span class="ui-data"${id}><span class="ui-data-label">${escapeHtml(dataLabel)}</span>${escapeHtml(value)}</span>`; } return `<span class="ui-data"${id}>${escapeHtml(value)}</span>`; default: return ''; } } function resolveBind(bind, ctx) { if (!ctx) return ''; const path = bind.replace(/^\$\.?/, '').toLowerCase(); switch (path) { case 'id': return ctx.id || ''; case 'pubkey': return ctx.pubkey || ''; case 'npub': return ctx.npub || ctx.pubkey || ''; case 'content': return ctx.content || ''; case 'time': case 'createdat': case 'created_at': return ctx.created_at ? new Date(ctx.created_at * 1000).toLocaleString() : ''; case 'kind': return String(ctx.kind || ''); default: return ''; } } function resolveTemplate(tmpl, ctx) { return tmpl.replace(/\{\{\s*\$\.?(\w+)\s*\}\}/g, (match, path) => { return resolveBind(path, ctx); }); } function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } // =========================================== // Bech32 decoding for nevent/note/naddr // =========================================== const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; function bech32Decode(str) { str = str.toLowerCase(); const pos = str.lastIndexOf('1'); if (pos < 1 || pos + 7 > str.length) throw new Error('Invalid bech32'); const hrp = str.slice(0, pos); const data = []; for (let i = pos + 1; i < str.length; i++) { const idx = BECH32_CHARSET.indexOf(str[i]); if (idx === -1) throw new Error('Invalid character'); data.push(idx); } // Remove checksum (last 6 chars) const payload = data.slice(0, -6); // Convert 5-bit to 8-bit let acc = 0, bits = 0; const bytes = []; for (const val of payload) { acc = (acc << 5) | val; bits += 5; while (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 0xff); } } return { hrp, bytes: new Uint8Array(bytes) }; } function parseNostrId(input) { input = input.trim(); // Already hex? if (/^[0-9a-f]{64}$/i.test(input)) { return { type: 'hex', id: input.toLowerCase() }; } // note1... (just the event id) if (input.startsWith('note1')) { const { bytes } = bech32Decode(input); const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); return { type: 'note', id: hex }; } // nevent1... (TLV encoded) if (input.startsWith('nevent1')) { const { bytes } = bech32Decode(input); return parseTLV(bytes, 'nevent'); } // naddr1... (TLV encoded) if (input.startsWith('naddr1')) { const { bytes } = bech32Decode(input); return parseTLV(bytes, 'naddr'); } return { type: 'unknown', raw: input }; } function parseTLV(bytes, type) { const result = { type }; let i = 0; while (i < bytes.length) { const t = bytes[i++]; const l = bytes[i++]; const v = bytes.slice(i, i + l); i += l; if (t === 0) { // special (event id for nevent, identifier for naddr) result.id = Array.from(v).map(b => b.toString(16).padStart(2, '0')).join(''); } else if (t === 1) { // relay result.relay = new TextDecoder().decode(v); } else if (t === 2) { // author result.author = Array.from(v).map(b => b.toString(16).padStart(2, '0')).join(''); } else if (t === 3) { // kind result.kind = v.reduce((acc, b) => acc * 256 + b, 0); } } return result; } // =========================================== // Demo UI Specs // =========================================== const DEMO_SPECS = { poll: { layout: 'card', title: 'Community Poll', elements: [ { type: 'heading', value: "What's the best approach to malleable UI?" }, { type: 'text', value: 'This entire UI is defined in JSON. The browser interprets it and renders HTML - no server required!' }, { type: 'hr' }, { type: 'text', value: 'Vote for your preferred approach:' }, { type: 'container', style: 'options', children: [ { type: 'button', label: 'Server-rendered', action: 'vote-server' }, { type: 'button', label: 'Client JS', action: 'vote-client' }, { type: 'button', label: 'Hybrid', action: 'vote-hybrid' } ]}, { type: 'hr' }, { type: 'heading', value: 'Event Data Bindings' }, { type: 'text', value: 'UI specs can bind to event data:' }, { type: 'data', bind: '$.id', label: 'Event ID: ' }, { type: 'data', bind: '$.pubkey', label: 'Author: ' }, { type: 'data', bind: '$.time', label: 'Created: ' } ], actions: [ { id: 'vote-server', publish: { kind: 7, content: 'server-rendered', tags: [['e', '{{$.id}}']] }}, { id: 'vote-client', publish: { kind: 7, content: 'client-js', tags: [['e', '{{$.id}}']] }}, { id: 'vote-hybrid', publish: { kind: 7, content: 'hybrid', tags: [['e', '{{$.id}}']] }} ] }, profile: { layout: 'card', title: 'Profile Card', elements: [ { type: 'heading', value: 'User Profile' }, { type: 'data', bind: '$.pubkey', label: 'Pubkey: ' }, { type: 'data', bind: '$.time', label: 'Last seen: ' }, { type: 'hr' }, { type: 'container', style: 'options', children: [ { type: 'button', label: 'Follow', action: 'follow' }, { type: 'button', label: 'Message', action: 'message' } ]} ], actions: [ { id: 'follow', publish: { kind: 3, content: '', tags: [['p', '{{$.pubkey}}']] }}, { id: 'message', link: '/html/messages/{{$.pubkey}}' } ] }, form: { layout: 'card', title: 'Feedback Form', elements: [ { type: 'heading', value: 'Send Feedback' }, { type: 'text', value: 'Your feedback helps improve this project.' }, { type: 'input', name: 'feedback', label: 'Your message:', id: 'feedback-input' }, { type: 'hr' }, { type: 'button', label: 'Submit Feedback', action: 'submit' } ], actions: [ { id: 'submit', publish: { kind: 1, content: 'Feedback: {{input:feedback}}', tags: [['t', 'feedback']] }} ] } }; // =========================================== // Alpine.js App // =========================================== function malleableApp() { return { // State eventInput: '', event: null, uiSpec: null, renderedHtml: '', error: null, loading: false, relayStatus: 'disconnected', statusMessage: 'Not connected', userPubkey: null, isDirectSpec: false, // Relay pool pool: null, async init() { this.pool = new RelayPool(); // Check for NIP-07 if (window.nostr) { try { this.userPubkey = await window.nostr.getPublicKey(); } catch (e) { console.log('NIP-07 available but not connected'); } } // Connect to relays this.relayStatus = 'connecting'; this.statusMessage = 'Connecting to relays...'; try { await this.pool.connect(); this.relayStatus = 'connected'; this.statusMessage = `Connected to ${this.pool.sockets.size} relays`; } catch (e) { this.relayStatus = 'error'; this.statusMessage = 'Failed to connect to relays'; } // Set up action executor window.executeAction = async (actionId) => { const { action, ctx } = window._malleableActions[actionId] || {}; if (!action) return; await this.executeAction(action, ctx); }; }, async connect() { if (!window.nostr) { this.error = 'No NIP-07 extension found. Install Alby, nos2x, or similar.'; return; } try { this.userPubkey = await window.nostr.getPublicKey(); } catch (e) { this.error = 'Failed to connect: ' + e.message; } }, disconnect() { this.userPubkey = null; }, async loadEvent() { this.error = null; this.event = null; this.uiSpec = null; this.renderedHtml = ''; this.isDirectSpec = false; const input = this.eventInput.trim(); if (!input) { this.error = 'Please enter an event ID or UI spec'; return; } // Check if it's direct JSON if (input.startsWith('{')) { const spec = parseUISpec(input); if (spec) { this.isDirectSpec = true; this.uiSpec = spec; const ctx = { id: 'direct-spec', pubkey: this.userPubkey || 'not-connected', created_at: Math.floor(Date.now() / 1000), kind: 0, content: input }; this.renderedHtml = renderUISpec(spec, ctx); return; } else { this.error = 'Invalid UI spec JSON'; return; } } // Parse as Nostr identifier this.loading = true; try { const parsed = parseNostrId(input); let filter = {}; if (parsed.type === 'hex' || parsed.type === 'note' || parsed.type === 'nevent') { filter = { ids: [parsed.id], limit: 1 }; } else if (parsed.type === 'naddr') { filter = { kinds: [parsed.kind], authors: [parsed.author], '#d': [parsed.id], limit: 1 }; } else { this.error = 'Unrecognized identifier format'; this.loading = false; return; } const event = await this.pool.fetchEvent(filter); if (!event) { this.error = 'Event not found'; this.loading = false; return; } this.event = event; this.uiSpec = parseUISpec(event.content); if (this.uiSpec) { const ctx = { id: event.id, pubkey: event.pubkey, created_at: event.created_at, kind: event.kind, content: event.content }; this.renderedHtml = renderUISpec(this.uiSpec, ctx); } } catch (e) { this.error = 'Failed to load event: ' + e.message; } this.loading = false; }, loadDemo(name = 'poll') { this.error = null; this.event = null; this.isDirectSpec = true; const spec = DEMO_SPECS[name] || DEMO_SPECS.poll; this.uiSpec = spec; const ctx = { id: 'demo-' + name, pubkey: this.userPubkey || 'demo-pubkey', created_at: Math.floor(Date.now() / 1000), kind: 1, content: JSON.stringify(spec) }; this.renderedHtml = renderUISpec(spec, ctx); this.eventInput = JSON.stringify(spec, null, 2); }, clear() { this.eventInput = ''; this.event = null; this.uiSpec = null; this.renderedHtml = ''; this.error = null; this.isDirectSpec = false; }, async executeAction(action, ctx) { if (action.link) { const url = resolveTemplate(action.link, ctx); window.location.href = url; return; } if (action.publish) { if (!window.nostr) { this.error = 'NIP-07 extension required to publish events'; return; } if (!this.userPubkey) { this.error = 'Please connect your NIP-07 extension first'; return; } try { // Resolve template in content let content = resolveTemplate(action.publish.content, ctx); // Check for input bindings like {{input:fieldname}} content = content.replace(/\{\{input:(\w+)\}\}/g, (match, fieldName) => { const input = document.querySelector(`[name="${fieldName}"]`); return input ? input.value : ''; }); // Resolve template in tags const tags = (action.publish.tags || []).map(tag => tag.map(v => resolveTemplate(v, ctx)) ); const event = { kind: action.publish.kind, content: content, tags: tags, created_at: Math.floor(Date.now() / 1000) }; // Sign with NIP-07 const signedEvent = await window.nostr.signEvent(event); // Publish to relays const relays = await this.pool.publish(signedEvent); this.error = null; alert(`Published to ${relays.length} relays!\n\nEvent ID: ${signedEvent.id}`); } catch (e) { this.error = 'Failed to publish: ' + e.message; } } } }; } </script> </body> </html>
vinney...axkl's avatar vinney...axkl
in the extreme case, a proper hypermedia server/client should let the ui and even functional aspects be entirely driven by notes' content. imagine what vibecoding would be like if it didn't even need to be in source code files or hosted anywhere.. entire uis and ux interactions composed soley of notes' content.
View quoted note →
vinney...axkl's avatar vinney...axkl
Check out what this screenshot note is replying to. I'm both excited and scared. Next steps: - host the malleable UI "client" (it's just a static html file) - make it easier to load a given note in the client - upgrade the "client" so that it can at least do these: 1. query + foreach - enables feeds/lists 2. embed - Enables composition 3. if/else - Conditional UIs 4. local state (??) - Interactive apps without publishing 5. computed - Derived values 6. pagination - Scale to large datasets X. ...eventually rewrite the client to be more of a "bootstrap/BIOS" so that the bot response actually _includes the "client code" html/js that it needs to run itself_. and the client replaces itself with that "OS". so essentially notes become "executable" apps. πŸ‘Ή (also the bot is offline so don't bother talking to it) View quoted note β†’ πŸ“ƒ.md
View quoted note →