Files
orgchain/src/main.js
T

395 lines
24 KiB
JavaScript
Raw Normal View History

// 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';
const $ = id => document.getElementById(id);
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);
// 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(); };
function say(message, bad = false) { const el = $('say'); el.textContent = message; el.classList.toggle('bad', bad); }
function load() {
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 });
render();
}
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();