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-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.
const ui = { holding : null , node : null , event : null , beat : null , verb : 'birth' , busy : false , replay : null , closing : null };
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 );
const select = next => Object . assign ( ui , { event : null , closing : null }, next );
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 } ` ;
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 ));
}
const fact = ([ k , v ]) => h ( 'div' , { class : 'f' }, h ( 'dt' , { text : k }), h ( 'dd' , { text : v }));
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 =>
h ( 'li' , {}, h ( 'b' , { text : d . title }), d . why && ` · ${ d . why } ` , h ( 'span' , { class : 'when' , text : ` ( ${ at ( d . beat ) } )` })))))),
];
}
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' ],
[ 'Inscription' , e . id ], body && [ 'Body' , body ], raw ? . block && [ 'Block' , raw . block ],
]. 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 ();