below this quote is a nostr client. save it as html and open it in your browser. see https://github.com/vcavallo/nostr-hypermedia/blob/hateoas-js/hateoas-js-readme.md for more information
nostr:nevent1qvzqqqqqqypzqth65u2mhdrd6klxkldg6acqyek3ze6tjyacz79dmdwzuc7esue3qythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qy88wumn8ghj7mn0wvhxcmmv9uqzpqld9jnemvqzh9fe4mpsw7vmq6edmk7thshec5x4z75uud8ky352klatxz
<!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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ===========================================
// 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>
Login to reply
Replies (2)
Amethyst codebox:
```
<!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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ===========================================
// 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>
```
broke: infrastructure as code
woke: client as note