2026-10-14 17:28:33 -07:00
// Orgchain: create and operate lineage holdings. Lineage gives the structure; the chain adds time.
// Every change goes through the lab's lock (orgchain/store.js) and the reducer (orgchain/lineage.js); this file draws and asks.
import './orgchain.css' ;
import { MetadataIndex } from './adapters/metadata-index.js' ;
import { census , lineageEvents , reduce , rows , slug , summary , trail } from './orgchain/lineage.js' ;
import { LAB_KEY , beat , decodeLab , emptyLab , encodeLab , mutate , readLab , setTempo , tick } from './orgchain/store.js' ;
import { SAMPLE , act , seedSample } from './orgchain/actions.js' ;
2026-10-14 20:14:11 -07:00
import { addressesOf , holderOf , keyOf , leavesOf , secretsOf } from './orgchain/keys.js' ;
2026-10-14 18:22:22 -07:00
import { readEnvelope } from './adapters/inscription.js' ;
2026-10-04 23:24:48 +00:00
const $ = id => document . getElementById ( id );
2026-10-14 17:28:33 -07:00
function h ( tag , attrs = {}, ... kids ) {
const el = document . createElement ( tag );
for ( const [ k , v ] of Object . entries ( attrs )) {
if ( v === null || v === undefined || v === false ) continue ;
if ( k === 'class' ) el . className = v ;
else if ( k === 'text' ) el . textContent = v ;
else if ( k . startsWith ( 'on' )) el . addEventListener ( k . slice ( 2 ), v );
else el . setAttribute ( k , v === true ? '' : String ( v ));
}
for ( const kid of kids . flat ( Infinity )) if ( kid !== null && kid !== undefined && kid !== false && kid !== '' ) el . append ( kid instanceof Node ? kid : String ( kid ));
return el ;
}
const plural = ( n , word ) => ` ${ n } ${ word }${ n === 1 ? '' : 's' } ` ;
const at = when => ( when === 'cued' ? 'cued' : `beat ${ when } ` );
const clockTime = ms => new Date ( ms ). toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' , second : '2-digit' });
const json = value => JSON . stringify ( value , ( k , v ) => ( typeof v === 'bigint' ? String ( v ) : v ), 2 );
2026-10-05 00:56:42 +00:00
2026-10-14 17:28:33 -07:00
// ui.beat: null means now (cued acts included); a number is that past beat, read-only.
2026-10-14 20:14:11 -07:00
const ui = { holding : null , node : null , event : null , beat : null , verb : 'birth' , busy : false , replay : null , closing : null , reveal : null };
2026-10-14 17:28:33 -07:00
let lab = emptyLab (), index = new MetadataIndex ( lab . chain ), events = [], live = reduce ([]), view = live ;
const tip = () => lab . chain . blocks . length ;
const cued = () => events . filter ( e => e . status === 'pending' ). length ;
const node = ( id , state = live ) => ( id ? state . nodes . get ( id ) ?? null : null );
2026-10-14 20:14:11 -07:00
const select = next => Object . assign ( ui , { event : null , closing : null , reveal : null }, next );
2026-10-14 17:28:33 -07:00
const pick = next => { select ( next ); render (); };
2026-10-05 01:23:21 +00:00
2026-10-14 17:28:33 -07:00
function say ( message , bad = false ) { const el = $ ( 'say' ); el . textContent = message ; el . classList . toggle ( 'bad' , bad ); }
2026-10-05 00:56:42 +00:00
2026-10-04 23:24:48 +00:00
function load () {
2026-10-14 17:28:33 -07:00
try { lab = readLab (); }
catch ( error ) { lab = emptyLab (); say ( `This browser's saved lab could not be read ( ${ error . message } ) Load a backup, or reset to start over.` , true ); }
derive ();
}
function derive () {
index = new MetadataIndex ( lab . chain );
events = lineageEvents ( index );
live = reduce ( events , { pending : true });
if ( ui . beat !== null && ui . beat >= tip ()) ui . beat = null ;
if ( ! node ( ui . holding )) ui . holding = live . roots . at ( - 1 ) ? . id ?? null ;
if ( ! node ( ui . node ) || node ( ui . node ). root !== ui . holding ) ui . node = ui . holding ;
if ( ui . event && ! live . log . some ( e => e . id === ui . event )) ui . event = null ;
view = ui . beat === null ? live : reduce ( events , { upTo : ui . beat });
2026-10-04 23:24:48 +00:00
render ();
}
2026-10-14 17:28:33 -07:00
function setBeat ( value ) {
ui . beat = value >= tip () ? null : Math . max ( 0 , value );
view = ui . beat === null ? live : reduce ( events , { upTo : ui . beat });
render ();
}
// One path for every change: the lab's lock re-reads, acts and saves. A failure changes nothing and says why.
async function run ( action , options ) {
if ( ui . busy ) return false ;
ui . busy = true ;
document . body . classList . add ( 'busy' );
try { lab = ( await mutate ( action , options )). lab ; return true ; }
catch ( error ) { say ( error ? . message || String ( error ), true ); return false ; }
finally { ui . busy = false ; document . body . classList . remove ( 'busy' ); derive (); }
}
// Act through a node, then say where it landed: at a beat, or cued for the next one.
async function perform ( op , by , fields , done ) {
let id = null ;
if ( ! ( await run ( l => { id = act ( l , op , by , fields ); }))) return false ;
const e = index . events . find ( x => x . id === id );
say ( done ( id , e ? . status === 'confirmed' ? `at beat ${ e . height } ` : 'cued for the next beat' ));
render ();
return true ;
}
async function landCue ( action ) {
const before = cued ();
if (( await run ( action )) && cued () < before ) say ( `Beat ${ tip () } : ${ plural ( before - cued (), 'cued act' ) } landed.` );
}
function render () { renderClock (); renderHoldings (); renderTree (); renderAct (); renderInspect (); renderTimeline (); }
function renderClock () {
const n = cued (), { tempo } = lab . clock ;
for ( const b of document . querySelectorAll ( '[data-tempo]' )) b . setAttribute ( 'aria-pressed' , String ( String ( tempo ) === b . dataset . tempo ));
$ ( 'clock-beat' ). textContent = String ( tip ());
$ ( 'clock-cued' ). textContent = n ? ` ${ n } cued · ${ cueNote () } ` : 'nothing cued' ;
$ ( 'beat-now' ). disabled = ! n ;
}
function cueNote () {
const { tempo , nextAt } = lab . clock ;
if ( tempo === 'manual' ) return 'waiting for Beat now' ;
if ( typeof tempo === 'number' && nextAt ) return `next beat in ${ Math . max ( 0 , Math . ceil (( nextAt - Date . now ()) / 1000 )) } s` ;
return 'landing on the next beat' ;
}
function renderHoldings () {
$ ( 'holdings-list' ). replaceChildren (... live . roots . map ( r => {
const c = census ( live , r . id ), on = r . id === ui . holding ;
const facts = [ at ( r . born ), plural ( c . orgs , 'org' ), plural ( c . agents , 'agent' ), plural ( c . belts , 'belt' ), r . status === 'closed' && 'closed' ]. filter ( Boolean );
return h ( 'li' , {}, h ( 'button' , { type : 'button' , class : `hold ${ on ? ' on' : '' }${ r . status === 'closed' ? ' gone' : '' } ` , 'aria-pressed' : String ( on ),
onclick : () => pick ({ holding : r . id , node : r . id }) }, h ( 'span' , { class : 'hn' , text : r . name }), ' ' , h ( 'span' , { class : 'hm' , text : facts . join ( ' · ' ) })));
}));
$ ( 'holdings-empty' ). hidden = live . roots . length > 0 ;
$ ( 'holdings-sub' ). textContent = live . roots . length ? String ( live . roots . length ) : '' ;
$ ( 'sample' ). disabled = live . roots . some ( r => r . name === SAMPLE && r . status === 'active' );
}
function renderTree () {
const box = $ ( 'tree-rows' ), root = node ( ui . holding );
$ ( 'tree-sub' ). textContent = root ? ` ${ root . name } · ${ ui . beat === null ? 'now' : `beat ${ ui . beat } of ${ tip () } ` } ` : '' ;
if ( ! root ) return box . replaceChildren ( h ( 'p' , { class : 'wait' , text : 'No holding yet. Found one, or load the yolopreneur sample.' }));
if ( ! view . nodes . has ( root . id )) return box . replaceChildren ( h ( 'p' , { class : 'wait' , text : ` ${ root . name } is founded at ${ at ( root . born ) } . Move the slider forward.` }));
const focused = box . contains ( document . activeElement ) ? document . activeElement . dataset . id : null ;
box . replaceChildren (... rows ( view , root . id ). map ( r => ( r . type === 'group'
? h ( 'div' , { class : 'row grp' }, h ( 'span' , { class : 'g' , 'aria-hidden' : 'true' , text : r . guide }), h ( 'span' , { class : 'gl' , text : r . label }))
: treeRow ( r ))));
if ( focused ) box . querySelector ( `[data-id=" ${ focused } "]` ) ? . focus ();
}
function treeRow ({ node : n , guide , path }) {
const on = n . id === ui . node ;
const cls = [ 'row' , on && 'on' , n . status === 'closed' && 'gone' , n . born === 'cued' && 'cue' ]. filter ( Boolean ). join ( ' ' );
return h ( 'div' , { class : cls }, h ( 'button' , { type : 'button' , class : 'sel' , 'data-id' : n . id , 'aria-pressed' : String ( on ), onclick : () => pick ({ node : n . id }) },
h ( 'span' , { class : 'g' , 'aria-hidden' : 'true' , text : guide }), h ( 'span' , { class : 'p' , text : ` ${ path } ` }), h ( 'span' , { class : 'l' , text : n . name }), ' ' ,
h ( 'span' , { class : 'v' }, marks ( n ))));
}
function marks ( n ) {
const list = [
n . kind === 'agent' && h ( 'span' , { class : 'mk' , text : 'agent' }),
n . edge === 'adopted' && h ( 'span' , { class : 'mk ok' , text : 'graduated' }),
n . status === 'closed' && h ( 'span' , { class : 'mk bad' , text : 'closed' }),
n . born === 'cued' && h ( 'span' , { class : 'mk' , text : 'cued' }),
... n . badges . map ( belt ),
]. filter ( Boolean );
return list . flatMap (( mark , i ) => ( i ? [ ' ' , mark ] : [ mark ])); // spaces keep accessible names readable
}
const belt = b => h ( 'span' , { class : `belt ${ b . belt } ` , title : ` ${ b . skill } : ${ b . belt } belt` }, b . skill , h ( 'span' , { class : 'sr' , text : ` ( ${ b . belt } belt)` }));
const below = ( x , n ) => { for ( let p = node ( x . parent ); p ; p = node ( p . parent )) if ( p . id === n . id ) return true ; return false ; };
// Who n can award: anyone else active in the holding, its own seats first (a belt is one skill for one seat).
const recipients = n => [... live . nodes . values ()]. filter ( x => x . root === n . root && x . id !== n . id && x . status === 'active' )
. map ( x => [ x , ( below ( x , n ) ? 0 : 2 ) + ( x . kind === 'agent' ? 0 : 1 )]). sort (( a , b ) => a [ 1 ] - b [ 1 ]). map (([ x ]) => x );
const derivedBelow = n => n . children . reduce (( sum , id ) => {
const c = node ( id );
return c && c . edge === 'derived' && c . status === 'active' ? sum + 1 + derivedBelow ( c ) : sum ;
}, 0 );
// Why a verb is unavailable for this node ('' when it is available). The reducer has the final word.
function blocked ( n ) {
const parent = node ( n . parent ), closed = n . status === 'closed' ? ` ${ n . name } is closed` : '' ;
return {
birth : closed || ( n . kind === 'agent' ? 'Agents do not give birth' : '' ),
award : closed || ( n . kind === 'agent' ? 'Agents do not award belts' : recipients ( n ). length ? '' : 'There is nobody else in this holding to award yet' ),
decide : closed ,
graduate : closed || ( ! parent ? ' A holding has no parent to graduate from ' : n.kind !== ' org ' ? ' Only orgs graduate '
: n.edge === ' adopted ' ? `${n.name} has already graduated` : parent.status !== ' active ' ? `${parent.name} is closed` : ''),
close: closed,
};
}
function renderAct() {
const n = node(ui.node), past = ui.beat !== null;
$(' act - none ').hidden = !!n;
$(' act - past ').hidden = !n || !past;
$(' act - on ').hidden = !n || past;
$(' act - sub ').textContent = n?.name ?? '';
$(' past - beat ').textContent = String(ui.beat ?? '');
if (!n || past) return;
const why = blocked(n), parent = node(n.parent);
for (const b of document.querySelectorAll(' [ data - verb ] ')) {
b.setAttribute(' aria - pressed ', String(b.dataset.verb === ui.verb));
b.classList.toggle(' off ', !!why[b.dataset.verb]);
}
for (const form of document.querySelectorAll(' form [ data - form ] ')) form.hidden = form.dataset.form !== ui.verb || !!why[ui.verb];
$(' act - why ').textContent = why[ui.verb] ? `${why[ui.verb]}.` : '';
for (const el of document.querySelectorAll(' # act - on [ data - name ] ')) el.textContent = n.name;
for (const el of document.querySelectorAll(' # act - on [ data - parent ] ')) el.textContent = parent?.name ?? '';
const to = $(' award - to '), keep = to.value;
const options = recipients(n).map(x => h(' option ', { value: x.id, text: `${trail(live, x.id)}${x.kind === ' agent ' ? ' ( agent ) ' : ''}` }));
const sig = list => list.map(o => `${o.value}=${o.textContent}`).join(' | ');
if (sig(options) !== sig([...to.options])) { to.replaceChildren(...options); if (options.some(o => o.value === keep)) to.value = keep; }
$(' graduate - go ').textContent = `Graduate ${n.name}`;
2026-10-14 18:22:22 -07:00
const signer = ui.verb === ' graduate ' ? parent : n, k = signer && keyOf(lab.keys, live, signer);
$(' act - key ').textContent = !signer || why[ui.verb] ? '' : k ? `Signs as ${signer.name}: ${k.path}` : ' Signs with the lab wallet : this holding predates lab keys . ';
2026-10-14 17:28:33 -07:00
const below = derivedBelow(n), arming = ui.closing === n.id;
$(' close - note ').textContent = below
? `Closing ${n.name} spends it for the last time, and its ${plural(below, ' derived descendant ')} close with it. Graduated children hold their own roots and stay open.`
: `Closing ${n.name} spends its inscription for the last time. Nothing can act through it afterwards.`;
$(' close - go ').textContent = arming ? `Yes, close ${n.name}` : `Close ${n.name}`;
$(' close - keep ').hidden = !arming;
}
function renderInspect() {
const box = $(' inspect - body '), e = ui.event ? live.log.find(x => x.id === ui.event) : null;
if (e) { $(' inspect - sub ').textContent = ' event '; return box.replaceChildren(...eventView(e)); }
const known = node(ui.node);
$(' inspect - sub ').textContent = known?.kind ?? '';
if (!known) return box.replaceChildren(h(' p ', { class: ' note pad ', text: ' Select a node in the tree , or an event in the timeline . ' }));
const n = node(ui.node, view);
if (!n) return box.replaceChildren(h(' p ', { class: ' note pad ', text: `${known.name} is not born yet at beat ${ui.beat}.` }));
box.replaceChildren(...nodeView(n));
}
2026-10-14 19:09:50 -07:00
const fact = ([k, v, note]) => h(' div ', { class: ' f ' }, h(' dt ', { text: k }), h(' dd ', {}, v, note && h(' span ', { class: ' fn ', text: note })));
2026-10-14 17:28:33 -07:00
function nodeView(n) {
const parent = node(n.parent, view);
const facts = [
[' Born ', n.born === ' cued ' ? ' cued for the next beat ' : `beat ${n.born}`],
parent && [' Parent ', `${parent.name} · ${n.kind} ${n.index}' `],
parent && ['Edge', n.edge === 'adopted' ? ` adopted at $ { at ( n . graduated )} : it holds its own root ` : ` derived : $ { parent . name } holds its seed `],
n.closed !== null && ['Closed', at(n.closed)],
['Children', n.children.length ? n.children.map(id => node(id, view)?.name).join(', ') : 'none yet'],
['Inscription', n.id],
].filter(Boolean);
return [
h('div', { class: 'ih' }, h('p', { class: 'eyebrow', text: trail(view, n.id) }), h('h3', { class: 'it', text: n.name }),
h('div', { class: 'chips' }, h('span', { class: 'chip', text: n.kind }), h('span', { class: ` chip $ { n . status === 'active' ? 'ok' : 'bad' } `, text: n.status }),
n.edge === 'adopted' && h('span', { class: 'chip ok', text: 'graduated' }), n.born === 'cued' && h('span', { class: 'chip', text: 'cued' }))),
h('div', { class: 'ib' }, h('dl', { class: 'fl' }, facts.map(fact)),
n.badges.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Belts' }), h('ul', { class: 'list' }, n.badges.map(b =>
h('li', {}, belt(b), h('span', { class: 'when', text: ` from $ { node ( b . by ) ? . name ?? '?' }, $ { at ( b . beat )} ` }))))),
n.decisions.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Decisions' }), h('ul', { class: 'list' }, n.decisions.map(d =>
2026-10-14 19:09:50 -07:00
h('li', {}, h('b', { text: d.title }), d.why && ` · $ { d . why } `, h('span', { class: 'when', text: ` ( $ { at ( d . beat )}) ` }))))),
keysView(n)),
2026-10-14 17:28:33 -07:00
];
}
2026-10-14 19:49:05 -07:00
// Lineage's keys for a node as of the viewed beat: its Taproot, SegWit and nostr addresses, where they come from, who
// holds its inscription now, and its leaves with the public lines to paste (the ones the lineage key tree page derives).
2026-10-14 18:22:22 -07:00
const HELD = { own: 'its own key', lab: 'the lab wallet', parent: "its parent's key (its handoff is cued)", other: 'a key outside this lab' };
2026-10-14 19:09:50 -07:00
const LEAF = { ssh: 'shell access, certified by the CA', git: 'commit signing', dkim: 'mail signing', wg: 'WireGuard peer key',
'ssh-ca': 'SSH certificate authority', mtls: 'TLS identity between nodes', noise: 'Headscale noise key' };
function keysView(n) {
2026-10-14 18:22:22 -07:00
const k = keyOf(lab.keys, view, n), holder = holderOf(lab, live, n.id);
2026-10-14 19:09:50 -07:00
const held = ['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent'];
if (!k) return h('div', { class: 'keys' }, h('p', { class: 'lab', text: 'Keys' }),
2026-10-14 20:14:11 -07:00
h('dl', { class: 'fl' }, [['Signs with', 'the lab wallet: this holding predates lab keys, so it has no addresses, leaves or secrets of its own'], held].map(fact)));
2026-10-14 19:49:05 -07:00
const agent = n.kind === 'agent', a = addressesOf(lab.keys, view, n), leaves = leavesOf(lab.keys, view, n);
const lines = leaves.map(([, , line]) => line).filter(Boolean), reset = 'Reset the lab for standard BIP-86 keys.';
const holds = agent ? "Holds this seat's inscription, and inscriptions sent to it land here." : "Holds this org's inscription and signs its acts. Inscriptions sent to it land here.";
const taproot = { bip86: ` BIP - 86 at $ { a . taproot . path }. $ { holds } `, tapkey: ` Its npub 's key with the BIP-86 tweak, the way v1.2 keyed this lab. ${holds} ${reset}`,
wallet: `An untweaked key at ${a.taproot.path}, the way v1.1 keyed this lab. ${holds} ${reset}` }[k.scheme];
const facts = [[' Taproot ', a.taproot.address, taproot], [' SegWit ', a.segwit.address, `BIP-84 at ${a.segwit.path}. For payments; this toy chain only moves Taproot.`],
2026-10-14 20:14:11 -07:00
[' Tapkey ', a.tapkey.address, ' Its npub as a Taproot address ( its nostr key with the BIP - 86 tweak ), so the npub alone finds it . Nostr secrets get pasted into clients , so treat it as an inbox . '],
2026-10-14 19:49:05 -07:00
[' npub ', a.npub.address, `NIP-06 at ${a.npub.path}. Its nostr name; the leaves below hang off it.`], [' From ', `${k.from} · account ${k.account}`], held];
2026-10-14 19:09:50 -07:00
return h(' div ', { class: ' keys ' },
h(' p ', { class: ' lab ', text: ' Keys ' }),
2026-10-14 19:49:05 -07:00
h(' dl ', { class: ' fl ' }, facts.map(fact)),
2026-10-14 19:09:50 -07:00
h(' p ', { class: ' lab ' }, ' Leaves ', h(' span ', { text: ` · HKDF-SHA256 from its ${agent ? ' nostr secret ' : ' seed '}` })),
h(' ul ', { class: ' list leaves ' }, leaves.map(([label, value]) =>
h(' li ', {}, h(' b ', { text: label }), h(' span ', { class: ' when ', text: ` ${LEAF[label]}` }), h(' code ', { text: value })))),
2026-10-14 20:14:11 -07:00
copyBlock(agent ? ' Public lines to paste ' : ' CA key for TrustedUserCAKeys ', lines.join(' \ n \ n ')),
secretsView(n, k));
}
// A code block with a Copy button; shown can lay the text out differently from what is copied.
function copyBlock(label, text, shown = text) {
const pre = h(' pre ', { text: shown });
const button = h(' button ', { type: ' button ', class: ' cp ', ' aria - label ': `Copy ${label}`, onclick: () => copy(text, button, pre) }, ' Copy ');
return h(' div ', { class: ' code ' }, h(' div ', { class: ' code - h cph ' }, h(' span ', { text: label }), button), pre);
}
async function copy(text, button, pre) {
const done = label => { button.textContent = label; setTimeout(() => { button.textContent = ' Copy '; }, 1400); };
try { await navigator.clipboard.writeText(text); done(' Copied '); } catch { getSelection().selectAllChildren(pre); done(' Selected '); }
}
// A node' s secrets behind Reveal , as on the lineage key tree page : hidden again whenever the selection changes .
function secretsView ( n , k ) {
const open = ui . reveal === n . id , s = open && secretsOf ( lab . keys , view , n ), org = node ( n . parent , view );
const toggle = () => { ui . reveal = open ? null : n . id ; renderInspect (); $ ( 'reveal' ) ? . focus (); };
const words = s ? . seed ? . mnemonic . split ( ' ' );
return h ( 'div' , { class : 'secret' },
h ( 'div' , { class : 'hz' }, h ( 'span' , { class : 'hz-l' , text : 'Secret · stays in this tab' }),
h ( 'button' , { type : 'button' , class : 'btn sm' , id : 'reveal' , 'aria-expanded' : String ( open ), onclick : toggle }, open ? 'Hide' : 'Reveal' )),
s && h ( 'div' , { class : 'sb' },
s . seed ? copyBlock ( `Seed · ${ s . seed . from } ` , s . seed . mnemonic , words . map (( w , i ) => ` ${ String ( i + 1 ). padStart ( 2 , '0' ) } ${ w } ` ). reduce (( rows , w , i ) => ( i % 3 ? rows [ rows . length - 1 ]. push ( w ) : rows . push ([ w ]), rows ), []). map ( r => r . join ( ' ' )). join ( '\n' ))
: h ( 'p' , { class : 'note' , text : `A seat has no seed of its own: these are account ${ k . account } of ${ org ? . name ?? 'its org' } 's seed.` }),
s . account . map (([ label , kind , text ]) => copyBlock ( ` ${ label } · ${ kind } ` , text )),
s . leaves . map (([ label , title , text ]) => copyBlock ( ` ${ label } · ${ title } ` , text ))));
2026-10-14 18:22:22 -07:00
}
// The key that signed an event's reveal, named when it belongs to the node that authorized it.
function signerOf ( e ) {
const witness = lab . chain . transactions . get ( e . id . slice ( 0 , - 2 )) ? . tx . vin . find ( i => i . witness ? . length === 3 ) ? . witness ;
if ( ! witness ) return '?' ;
const pubkey = readEnvelope ( witness ). pubkey , by = node ( e . parent ), own = e . op === 'found' ? node ( e . node ) : null ;
const named = [[ own && keyOf ( lab . keys , live , own ), ` ${ own ? . name } 's root` ], [ by && keyOf ( lab . keys , live , by ), ` ${ by ? . name } 's key` ],
[ by ? . edge === 'adopted' && keyOf ( lab . keys , live , { ... by , edge : 'derived' }), ` ${ by ? . name } 's derived key, from before it graduated` ]];
for ( const [ k , label ] of named ) if ( k && k . wallet . pubkey === pubkey ) return label ;
return lab . wallet ? . pubkey === pubkey ? 'the lab wallet' : `a key outside this lab ( ${ pubkey . slice ( 0 , 12 ) } …)` ;
}
2026-10-14 17:28:33 -07:00
function eventView ( e ) {
const raw = index . events . find ( x => x . id === e . id ), body = lab . chain . transactions . get ( e . id . slice ( 0 , - 2 )) ? . inscription ? . text ;
const facts = [
[ 'When' , e . pending ? 'cued for the next beat' : `beat ${ e . beat } ` ],
[ 'Authorized by' , e . parent ? ` ${ node ( e . parent ) ? . name ?? 'an unknown inscription' } , whose inscription this event spent` : 'nothing: a founding has no parent' ],
2026-10-14 18:22:22 -07:00
[ 'Signed by' , signerOf ( e )], [ 'Inscription' , e . id ], body && [ 'Body' , body ], raw ? . block && [ 'Block' , raw . block ],
2026-10-14 17:28:33 -07:00
]. filter ( Boolean );
const entry = raw && { id : raw . id , status : raw . status , height : raw . height , block : raw . block , parent : raw . parent ?? null ,
metaprotocol : raw . metaprotocol ?? null , contentType : raw . contentType , sat : raw . sat , location : raw . location };
return [
h ( 'div' , { class : 'ih' }, h ( 'p' , { class : 'eyebrow' , text : ` ${ e . op } · ${ e . pending ? 'cued' : `beat ${ e . beat } ` } ` }), h ( 'h3' , { class : 'it' , text : summary ( e , live ) }),
h ( 'div' , { class : 'chips' }, h ( 'span' , { class : 'chip' , text : raw ? . protocol ?? '?' }), h ( 'span' , { class : `chip ${ e . ok ? 'ok' : 'bad' } ` , text : e . ok ? 'applied' : 'rejected' })),
! e . ok && h ( 'p' , { class : 'idesc' , text : 'The reducer rejected this event. It stays on the chain, but it does not change the tree.' })),
h ( 'div' , { class : 'ib' }, h ( 'dl' , { class : 'fl' }, facts . map ( fact )),
h ( 'div' , { class : 'code' }, h ( 'p' , { class : 'code-h' , text : 'Metadata · CBOR, tag 5' }), h ( 'pre' , { text : json ( e . meta ) })),
entry && h ( 'div' , { class : 'code' }, h ( 'p' , { class : 'code-h' , text : 'Raw index entry' }), h ( 'pre' , { text : json ( entry ) }))),
];
}
function renderTimeline () {
const t = tip (), slider = $ ( 'beat' ), root = node ( ui . holding );
slider . max = String ( t );
slider . value = String ( ui . beat ?? t );
slider . disabled = t === 0 ;
$ ( 'beat-label' ). textContent = ui . beat === null ? `Now · beat ${ t } ` : `Beat ${ ui . beat } of ${ t } ` ;
$ ( 'replay' ). textContent = ui . replay ? 'Stop' : 'Replay' ;
$ ( 'replay' ). disabled = ! root || t === 0 ;
$ ( 'to-now' ). disabled = ui . beat === null ;
$ ( 'timeline-sub' ). textContent = root ? . name ?? '' ;
const groups = new Map ();
for ( const e of live . log ) if ( e . root && e . root === ui . holding ) { const k = e . pending ? 'cued' : e . beat ; groups . set ( k , [...( groups . get ( k ) ?? []), e ]); }
const keys = [... groups . keys ()]. sort (( a , b ) => ( a === 'cued' ? - 1 : b === 'cued' ? 1 : b - a ));
if ( ! keys . length ) return $ ( 'timeline-list' ). replaceChildren ( h ( 'li' , { class : 'wait' , text : root ? 'Nothing yet.' : 'Found a holding to start its timeline.' }));
$ ( 'timeline-list' ). replaceChildren (... keys . map ( k => {
const block = k === 'cued' ? null : lab . chain . blocks [ k - 1 ];
const later = ui . beat !== null && ( k === 'cued' || k > ui . beat ), current = ui . beat !== null && k === ui . beat ;
return h ( 'li' , { class : `beat ${ later ? ' later' : '' }${ current ? ' now' : '' } ` },
h ( 'p' , { class : 'bh' }, h ( 'span' , { class : 'bn' , text : k === 'cued' ? 'Cued' : `Beat ${ k } ` }),
h ( 'span' , { class : 'bt' , text : block ? clockTime ( block . time ) : lab . clock . tempo === 'manual' ? 'waiting for Beat now' : 'landing on the next beat' })),
h ( 'ol' , { class : 'evs' }, groups . get ( k ). map ( e => {
const [ verb , ... rest ] = summary ( e , live ). split ( ' ' );
return h ( 'li' , {}, h ( 'button' , { type : 'button' , class : `ev ${ e . ok ? '' : ' bad' }${ e . id === ui . event ? ' on' : '' } ` , 'aria-pressed' : String ( e . id === ui . event ),
onclick : () => pick ({ event : e . id , node : e . node ?? ui . node }) },
h ( 'span' , { class : 'op' , text : e . ok ? verb : `✕ ${ verb . replace ( /:$/ , '' ) } ` }), ' ' , h ( 'span' , { class : 'es' , text : rest . join ( ' ' ) })));
})));
}));
}
// Verbs
$ ( 'f-found' ). addEventListener ( 'submit' , async event => {
event . preventDefault ();
const form = event . currentTarget , name = slug ( form . elements . name . value );
if ( ! name ) return say ( 'Name the holding: lowercase letters, digits and hyphens.' , true );
if ( await perform ( 'found' , null , { name }, ( id , where ) => { select ({ holding : id , node : id }); return ` ${ name } is founded, ${ where } .` ; })) form . reset ();
});
$ ( 'sample' ). addEventListener ( 'click' , async () => {
let id = null ;
say ( 'Rehearsing the yolopreneur tree over six beats…' );
if ( ! ( await run ( l => { id = seedSample ( l ); }))) return ;
pick ({ holding : id , node : id });
say ( 'Loaded the yolopreneur sample: six beats of history. Press Replay to watch it grow.' );
});
$ ( 'f-birth' ). addEventListener ( 'submit' , async event => {
event . preventDefault ();
const form = event . currentTarget , n = node ( ui . node ), name = slug ( form . elements . name . value ), kind = form . elements . kind . value ;
if ( ! name ) return say ( 'Name the child: lowercase letters, digits and hyphens.' , true );
if ( await perform ( 'birth' , n . id , { name , kind }, ( id , where ) => ` ${ name } is born to ${ n . name } , ${ where } .` )) form . elements . name . value = '' ;
});
$ ( 'f-award' ). addEventListener ( 'submit' , async event => {
event . preventDefault ();
const form = event . currentTarget , n = node ( ui . node ), to = form . elements . to . value , skill = slug ( form . elements . skill . value ), level = form . elements . belt . value ;
if ( ! skill ) return say ( 'Name the skill, for example launch.' , true );
if ( await perform ( 'award' , n . id , { to , skill , belt : level }, ( id , where ) => ` ${ node ( to ) ? . name } holds a ${ level } belt in ${ skill } , ${ where } .` )) form . elements . skill . value = '' ;
});
$ ( 'f-decide' ). addEventListener ( 'submit' , async event => {
event . preventDefault ();
const form = event . currentTarget , n = node ( ui . node ), title = form . elements . title . value . trim (), why = form . elements . why . value . trim ();
if ( ! title ) return say ( 'A decision needs a title.' , true );
if ( await perform ( 'decide' , n . id , { title , why }, ( id , where ) => ` ${ n . name } decided “ ${ title } ”, ${ where } .` )) form . reset ();
});
$ ( 'f-graduate' ). addEventListener ( 'submit' , async event => {
event . preventDefault ();
const n = node ( ui . node ), parent = node ( n . parent );
await perform ( 'graduate' , parent . id , { of : n . id }, ( id , where ) => ` ${ n . name } graduates from ${ parent . name } , ${ where } . Its edge is adopted now.` );
});
$ ( 'f-close' ). addEventListener ( 'submit' , async event => {
event . preventDefault ();
const n = node ( ui . node );
if ( ui . closing !== n . id ) { ui . closing = n . id ; renderAct (); return $ ( 'close-go' ). focus (); }
ui . closing = null ;
await perform ( 'close' , n . id , {}, ( id , where ) => ` ${ n . name } is closed, ${ where } .` );
});
$ ( 'close-keep' ). addEventListener ( 'click' , () => { ui . closing = null ; renderAct (); $ ( 'close-go' ). focus (); });
for ( const b of document . querySelectorAll ( '[data-verb]' )) b . addEventListener ( 'click' , () => { ui . verb = b . dataset . verb ; ui . closing = null ; renderAct (); });
// Time: the tempo, Beat now, and the clock that lands cued acts when their beat is due
for ( const b of document . querySelectorAll ( '[data-tempo]' )) b . addEventListener ( 'click' , async () => {
const value = b . dataset . tempo , tempo = /^\d+$/ . test ( value ) ? Number ( value ) : value ;
if ( ! ( await run ( l => setTempo ( l , tempo )))) return ;
say ( value === 'each' ? 'Each act is its own beat.' : value === 'manual' ? 'Acts are cued until you press Beat now.'
: `Acts are cued and land together on the next beat, at most ${ value } seconds after the first.` );
});
$ ( 'beat-now' ). addEventListener ( 'click' , () => landCue ( l => ( l . chain . mempool . length ? beat ( l ) : false )));
setInterval (() => {
renderClock ();
const { tempo , nextAt } = lab . clock ;
if ( ui . busy || document . hidden || typeof tempo !== 'number' || ! lab . chain . mempool . length || ( nextAt && Date . now () < nextAt )) return ;
landCue ( l => tick ( l ));
}, 1000 );
addEventListener ( 'storage' , event => { if ( event . key === LAB_KEY || event . key === null ) load (); });
// The timeline: any beat, a replay, and back to now
function stopReplay () { if ( ui . replay ) { clearInterval ( ui . replay ); ui . replay = null ; renderTimeline (); } }
function startReplay () {
const root = node ( ui . holding );
if ( ! root || ! tip ()) return ;
setBeat ( typeof root . born === 'number' ? root . born : tip ());
if ( ui . beat === null ) return say ( 'Nothing to replay yet: this holding has one beat of history.' );
ui . replay = setInterval (() => { if ( ui . beat === null || ui . beat + 1 >= tip ()) stopReplay (); setBeat ( ui . beat === null ? tip () : ui . beat + 1 ); }, 800 );
renderTimeline ();
}
$ ( 'beat' ). addEventListener ( 'input' , event => { stopReplay (); setBeat ( Number ( event . target . value )); });
$ ( 'replay' ). addEventListener ( 'click' , () => ( ui . replay ? stopReplay () : startReplay ()));
for ( const id of [ 'to-now' , 'past-now' ]) $ ( id ). addEventListener ( 'click' , () => { stopReplay (); setBeat ( tip ()); });
// Theme: system, light, dark (public/theme.js applies the saved choice before first paint)
const THEMES = [ 'system' , 'light' , 'dark' ];
const showTheme = () => { $ ( 'theme' ). textContent = `Theme: ${ document . documentElement . dataset . theme ?? 'system' } ` ; };
$ ( 'theme' ). addEventListener ( 'click' , () => {
const next = THEMES [( THEMES . indexOf ( document . documentElement . dataset . theme ?? 'system' ) + 1 ) % THEMES . length ];
if ( next === 'system' ) delete document . documentElement . dataset . theme ; else document . documentElement . dataset . theme = next ;
try { if ( next === 'system' ) localStorage . removeItem ( 'orgchain-theme' ); else localStorage . setItem ( 'orgchain-theme' , next ); } catch { /* not remembered */ }
showTheme ();
});
// The lab: save, load and reset
function confirmBox ( text , label , onYes ) {
const box = $ ( 'confirm' );
box . replaceChildren ( h ( 'span' , { text }), h ( 'button' , { type : 'button' , class : 'btn sm danger' , text : label , onclick : () => { box . hidden = true ; onYes (); } }),
h ( 'button' , { type : 'button' , class : 'btn sm' , text : 'Cancel' , onclick : () => { box . hidden = true ; } }));
box . hidden = false ;
box . querySelector ( 'button' ). focus ();
}
const forgiving = { read : () => { try { return readLab (); } catch { return emptyLab (); } } };
async function replaceLab ( next , message ) {
stopReplay ();
if ( ! ( await run ( l => { Object . assign ( l , next ()); }, forgiving ))) return ;
Object . assign ( ui , { holding : null , node : null , event : null , beat : null , closing : null });
derive ();
say ( message );
}
$ ( 'save' ). addEventListener ( 'click' , () => {
const link = h ( 'a' , { href : URL . createObjectURL ( new Blob ([ encodeLab ( lab )], { type : 'application/json' })), download : `orgchain-lab- ${ new Date (). toISOString (). slice ( 0 , 10 ) } .json` });
document . body . append ( link );
link . click ();
link . remove ();
setTimeout (() => URL . revokeObjectURL ( link . href ), 5000 );
say ( 'Saved a backup of this lab, test wallet included. Load it in any browser to carry on.' );
});
$ ( 'load' ). addEventListener ( 'click' , () => $ ( 'load-file' ). click ());
$ ( 'load-file' ). addEventListener ( 'change' , async event => {
const file = event . target . files ? .[ 0 ];
event . target . value = '' ;
if ( ! file ) return ;
let next ;
try { next = decodeLab ( await file . text ()); } catch ( error ) { return say ( ` ${ file . name } is not an Orgchain lab: ${ error . message } ` , true ); }
confirmBox ( `Replace this browser's lab with ${ file . name } ?` , 'Replace' , () => replaceLab (() => next , `Loaded ${ file . name } .` ));
});
$ ( 'reset' ). addEventListener ( 'click' , () => confirmBox ( "Erase this browser's lab: every holding, the chain and the test wallet?" , 'Erase' ,
() => replaceLab ( emptyLab , 'Erased. This is a fresh lab.' )));
showTheme ();
load ();