tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

app.ts (45451B)


      1 import { exampleGroups, exampleGroup, selectorLibraries, libraryLabel } from './program-catalogue.js';
      2 import { importPicker } from './import-picker.js';
      3 import { localModulesUI } from './local-modules-ui.js';
      4 import { outputReveal } from './output-reveal.js';
      5 import { syntaxEditor, tokenize, paintSyntax } from './syntax.js';
      6 import { printLiteral } from './literal-input.js';
      7 import { backend, backends, runtimeStats, type Backend } from './runtime-backends.js';
      8 import { demoInputLabels } from './demo-inputs.js';
      9 import { argumentEditor } from './arguments-ui.js';
     10 import { createOutput } from './output-app.js';
     11 import { setupInspector } from './inspector.js';
     12 import { isTreeResult } from './tree-result.js';
     13 import { treeFormat } from './tree-export.js';
     14 import { ActiveClock } from './clock.js';
     15 import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout, type Operation } from './execution.js';
     16 import { sourceText } from './compiler.js';
     17 import { packageIndex, packageReleases, type Release } from './packages.js';
     18 import type { CompilerMessage } from './compiler-protocol.js';
     19 import { bundleHash, fetchBytes, type BundleHash } from './bundle.js';
     20 import { Graph } from './graph.js';
     21 import { line, literals, outputType, type OutputType } from './codecs.js';
     22 import type { CatalogueEntry, ClientMessage, WorkerMessage, RequestId } from './protocol.js';
     23 function el<T extends HTMLElement = HTMLElement>(id: string): T {
     24   const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); return element as T;
     25 }
     26 const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile');
     27 const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle');
     28 let sourceAvailable = false;
     29 const expressionEditor = el<HTMLTextAreaElement>('expression-editor');
     30 const refreshSourceSyntax = syntaxEditor(sourceEditor), refreshExpressionSyntax = syntaxEditor(expressionEditor);
     31 const revealOutput = outputReveal(document.querySelector<HTMLElement>('.editor-toolbar')!);
     32 const guidedInputs = el<HTMLInputElement>('use-guided-inputs');
     33 let editRevision = 0, composing = false, idleDue = 0;
     34 let idleTimer: ReturnType<typeof setTimeout> | undefined;
     35 function cancelIdleCompile(): void { clearTimeout(idleTimer); idleTimer = undefined; }
     36 function queueCompile(delay = 650): void {
     37   cancelIdleCompile();
     38   if (composing) return;
     39   idleDue = performance.now() + delay;
     40   idleTimer = setTimeout(() => {
     41     idleTimer = undefined;
     42     if (!busy && draft) compileDraft();
     43   }, delay);
     44 }
     45 function markPreviousResult(): void {
     46   revealOutput.cancel();
     47   if (el('result').textContent || el('terminal').textContent) el('result-stale').hidden = false;
     48 }
     49 function presetExpression(name = exportsInput.value || preferredName): void {
     50   expressionEditor.value = [name, ...argumentsUI.read().map(printLiteral)].join(' ');
     51   refreshExpressionSyntax();
     52 }
     53 function expressionInput(): { expression?: string } {
     54   return guidedInputs.checked ? {} : { expression: sourceText(expressionEditor.value) };
     55 }
     56 
     57 const backendPicker = el<HTMLSelectElement>('runtime-backend');
     58 let selectedBackend: Backend = backend(backendPicker.value);
     59 const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url);
     60 let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined;
     61 let compiledSource: string | undefined, compiledRevision: number | undefined;
     62 let compilationStats = '', preferredName = 'id';
     63 let compilerTimer: ReturnType<typeof setInterval> | undefined;
     64 function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; }
     65 let releases: Release[] = [];
     66 const programPicker = el<HTMLSelectElement>('program-picker');
     67 const versionPicker = el<HTMLSelectElement>('package-version');
     68 let versionPackage: string | undefined;
     69 const hashInput = el<HTMLInputElement>('bundle-hash'), exportsInput = el<HTMLSelectElement>('export');
     70 const argsInput = el<HTMLTextAreaElement>('arguments'), resultCodec = el<HTMLSelectElement>('output-type');
     71 const argumentsUI = argumentEditor(argsInput);
     72 const mode = el<HTMLSelectElement>('mode'), steps = el<HTMLInputElement>('step-mode');
     73 const runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElement>('stop'), nextButton = el<HTMLButtonElement>('next');
     74 let operation: Operation | undefined;
     75 const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout'));
     76 const shareButton = el<HTMLButtonElement>('share');
     77 const outputUI = createOutput();
     78 const outputTarget = el<HTMLSelectElement>('output-target');
     79 const storedOutput = () => outputTarget.value === 'definition';
     80 const visibleCodec = (value: string): string => value === 'tree' || value === 'list' ? 'tricu' : value;
     81 let resultInterpretation = 'tricu';
     82 function clearTreeExport(): void { if (storedOutput()) outputUI.clear(); }
     83 function syncOutputTarget(): void {
     84   for (const option of resultCodec.options) option.disabled = storedOutput() && !isTreeResult(option.value);
     85   if (storedOutput() && !isTreeResult(resultCodec.value)) resultCodec.value = 'tricu';
     86   controls(busy);
     87 }
     88 outputTarget.onchange = () => {
     89   if (busy) return;
     90   revealOutput.cancel();
     91   if (storedOutput()) resultInterpretation = resultCodec.value;
     92   else resultCodec.value = resultInterpretation;
     93   clearOutput(); syncOutputTarget();
     94 };
     95 const inputForm = el<HTMLFormElement>('input-form'), lineInput = el<HTMLInputElement>('line-input');
     96 let entries: CatalogueEntry[] = [], selected: CatalogueEntry | undefined;
     97 let selection: { hash: BundleHash; localId?: string } | undefined;
     98 let loaded = false, worker: Worker | undefined, pending: { kind: 'input' | 'step'; id: RequestId } | undefined;
     99 let automaticRun = false;
    100 let savedRun: Extract<ClientMessage, { type: 'run' }> | undefined;
    101 let preferredExport: string | undefined;
    102 const traceRows = new Map<number, HTMLLIElement>();
    103 const localModules = localModulesUI(() => {
    104   if (!draft) return;
    105   if (busy && operation !== 'compiling') return;
    106   editRevision++; markPreviousResult();
    107   if (!busy) { dispose(); el('status').textContent = 'Changes pending'; }
    108   queueCompile();
    109 });
    110 const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value,
    111   available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly,
    112   local: () => localModules.entries(), inputs: () => localModules.inputs() });
    113 const exportModuleButton = el<HTMLButtonElement>('export-compiled-module');
    114 exportModuleButton.onclick = () => {
    115   if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) return;
    116   cancelIdleCompile(); operation = 'exporting'; controls(true); deadline.reset(Infinity); clock();
    117   el('status').textContent = 'Exporting module';
    118   el('compiled-module-status').textContent = 'Observing public trees; Stop is available…';
    119   worker.postMessage({ type: 'export-module' });
    120 };
    121 function pauseClock(): void { deadline.pause(); }
    122 function clock(): void { deadline.start(); }
    123 function runClock(): void { operation = 'running'; deadline.reset(Infinity); clock(); }
    124 function controls(working: boolean): void {
    125   busy = working;
    126   localModules.disable(busy);
    127   exportModuleButton.disabled = busy || !draft || !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision;
    128   backendPicker.disabled = busy;
    129   programPicker.disabled = busy || entries.length === 0;
    130   versionPicker.disabled = busy; hashInput.disabled = busy;
    131   el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value);
    132   el<HTMLButtonElement>('load').disabled = busy;
    133   runButton.disabled = busy || (storedOutput() ? draft || !loaded : draft ? !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision : !loaded);
    134   runButton.textContent = storedOutput() ? 'Show definition' : 'Evaluate';
    135   el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision);
    136   el('draft-run-note').textContent = compiled
    137     ? 'Source changed. Evaluation waits for the current revision to compile.'
    138     : 'Waiting for compilation. Published programs are never used as a fallback.';
    139   argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []);
    140   el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent;
    141   nextButton.hidden = storedOutput() || !steps.checked;
    142   runButton.classList.toggle('draft', draft);
    143   runButton.classList.toggle('primary', !draft || compiled);
    144   compileButton.classList.remove('primary');
    145   compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); compileButton.hidden = !draft; restoreButton.hidden = !draft;
    146   sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft;
    147   const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined);
    148   el<HTMLButtonElement>('add-import').hidden = !editableSource;
    149   el<HTMLButtonElement>('add-import').disabled = busy;
    150   el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information';
    151   el('source-availability').hidden = editableSource || !loaded;
    152   el('source-availability').textContent = sourceAvailable
    153     ? 'Source is shown for reference only; the published implementation cannot be edited here. Evaluate expressions against its exports below.'
    154     : 'This is a compiled package. Editable source is not included; you can evaluate expressions against its exports or export its stored definitions.';
    155   restoreButton.disabled = busy || originalSource === undefined || !draft;
    156   downloadButton.disabled = busy || draft || downloadHash() === undefined;
    157   el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified';
    158   const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision);
    159   el('authorship').classList.toggle('modified', sourceModified);
    160   sourceEditor.classList.toggle('modified', sourceModified);
    161   if (draft && storedOutput()) { el('draft-run-note').hidden = false; el('draft-run-note').textContent = 'Stored definitions require a published package. Choose Evaluated result or restore the published source.'; }
    162   for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy;
    163   stopButton.disabled = !busy && !draft; stopButton.hidden = !busy;
    164   el<HTMLButtonElement>('stop-run').disabled = stopButton.disabled;
    165   exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy);
    166   expressionEditor.disabled = busy && operation !== 'compiling'; guidedInputs.disabled = busy;
    167   el('expression-inputs').hidden = storedOutput() || guidedInputs.checked; el('guided-inputs').hidden = storedOutput() || !guidedInputs.checked;
    168   mode.disabled = busy || storedOutput(); outputTarget.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy;
    169   shareButton.disabled = busy || draft || !loaded; el<HTMLButtonElement>('reset').disabled = busy;
    170   outputUI.controls(busy);
    171   refreshSourceSyntax(); refreshExpressionSyntax();
    172 }
    173 function dispose(): void {
    174   cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined;
    175   compiledSource = undefined; compiledRevision = undefined; compiled = false; pending = undefined;
    176   inputForm.hidden = true; nextButton.disabled = true; controls(false);
    177 }
    178 function clearOutput(): void {
    179   el('result-stale').hidden = true; el('compile-diagnostic').hidden = true;
    180   el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = '';
    181   outputUI.clear();
    182   el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = '';
    183 }
    184 function finish(message: string, error = false, reason: 'failure' | 'timeout' | 'cancelled' = 'failure'): void {
    185   if (error && reason === 'failure') message = operationFailure(operation, message);
    186   if (operation === 'exporting' && draft) el('compiled-module-status').textContent = message;
    187   if (operation === 'compiling' && reason === 'failure') {
    188     dispose(); el('status').textContent = 'Compilation failed';
    189     el('compiler-stats').textContent = 'Compilation failed; runtime discarded.';
    190     el('compile-diagnostic').textContent = message; el('compile-diagnostic').hidden = false;
    191     return;
    192   }
    193   if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`;
    194   if (error && operation !== 'downloading') outputUI.clear();
    195   if (operation === 'downloading') { el('bundle-download-status').textContent = message; el('bundle-download-status').classList.toggle('error', error); }
    196   const downloadOnly = operation === 'downloading';
    197   dispose(); el('status').textContent = error ? 'Stopped' : 'Finished';
    198   if (!downloadOnly) { el('result').textContent = message; el('result').className = error ? 'error' : ''; revealOutput.reveal(el('result')); }
    199   for (const row of traceRows.values()) row.classList.remove('pending');
    200 }
    201 function setPreset(entry: CatalogueEntry, name = entry.export): void {
    202   const preset = entry.presets?.[name] ?? entry;
    203   argumentsUI.labels(demoInputLabels(entry.id, name));
    204   argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = visibleCodec(preset.output); mode.value = preset.mode; presetExpression(name);
    205 }
    206 function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: (message: ClientMessage) => void): void {
    207   operation = 'exporting'; clearOutput(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock();
    208   el('status').textContent = 'Exporting definition'; send(message);
    209 }
    210 function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoad?: Extract<ClientMessage, { type: 'export' }>): void {
    211   if (!selection) return;
    212   dispose(); operation = 'loading'; deadline.reset(PACKAGE_LOAD_MS); automaticRun = runAfterLoad;
    213   const current = new Worker(workerURL(), { type: 'module' }); worker = current;
    214   let currentVerified = false;
    215   controls(true); el('status').textContent = 'Loading package'; el('package-info').textContent = 'Verifying package…'; clock();
    216   const send = (message: ClientMessage): void => current.postMessage(message);
    217   current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => {
    218     if (worker !== current) return;
    219     switch (data.type) {
    220       case 'state':
    221         if (data.state === 'running') { pending = undefined; clock(); el('status').textContent = operation === 'loading' ? 'Loading package' : 'Running'; }
    222         else {
    223           pauseClock(); pending = { kind: data.state, id: data.id };
    224           el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action';
    225           inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step';
    226           revealOutput.reveal(data.state === 'input' ? inputForm : nextButton);
    227         }
    228         break;
    229       case 'loaded': {
    230         currentVerified = true; loaded = true;
    231         el('compiler-stats').textContent = 'Published package · no local compilation.';
    232         const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export);
    233         exportsInput.replaceChildren();
    234         for (const item of data.exports) {
    235           const option = document.createElement('option'); option.value = item.name; option.textContent = item.name; exportsInput.append(option);
    236         }
    237         if (oldExport && data.exports.some(e => e.name === oldExport)) exportsInput.value = oldExport;
    238         preferredExport = undefined;
    239         el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`;
    240         if (!preserveInputs && !automaticRun && !exportAfterLoad) {
    241           originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined;
    242           sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined;
    243           el('source-notes').hidden = originalSource !== undefined;
    244         }
    245         if (!preserveInputs && !automaticRun && !exportAfterLoad) presetExpression();
    246         if (storedOutput() && !isTreeResult(resultCodec.value)) resultCodec.value = 'tricu';
    247         sourceAvailable = data.source !== null;
    248         el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable';
    249         el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`;
    250         if (data.source !== null) paintSyntax(el('source'), tokenize(data.source));
    251         if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); }
    252         else if (automaticRun && savedRun) { pauseClock(); runClock(); automaticRun = false; send(savedRun); }
    253         else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; }
    254         break;
    255       }
    256       case 'exported': {
    257         pauseClock();
    258         if (data.hash !== selection?.hash || data.export !== exportsInput.value || data.format !== resultCodec.value || !storedOutput()) break;
    259         outputUI.file(data.file, data.export, `Stored definition ${data.export} · SHA-256 ${data.hash} · ${data.format}`);
    260         revealOutput.reveal(el('result'));
    261         operation = undefined; controls(false); el('status').textContent = 'Ready';
    262         break;
    263       }
    264       case 'export-error': finish(data.message, true); break;
    265       case 'trace': {
    266         el('trace-empty').hidden = true; const event = data.event;
    267         if (data.phase === 'before') {
    268           const row = document.createElement('li'); row.className = 'pending';
    269           row.textContent = `${String(event.step).padStart(2, '0')}  ${event.name} ${JSON.stringify(data.event.detail)}  [stack ${data.event.depth}]`;
    270           traceRows.set(event.step, row); el('trace').append(row);
    271         } else {
    272           const row = traceRows.get(event.step);
    273           if (row) { row.textContent += `\n    → ${JSON.stringify(data.event.value)}`; row.classList.remove('pending'); }
    274         }
    275         el('trace').scrollTop = el('trace').scrollHeight; break;
    276       }
    277       case 'output': el('terminal').textContent += data.text; revealOutput.reveal(el('io-console')); el('terminal').scrollTop = el('terminal').scrollHeight; break;
    278       case 'done': {
    279         idleDraft();
    280         el('status').textContent = 'Finished';
    281         outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`);
    282         revealOutput.reveal(el('result'));
    283         el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`;
    284         break;
    285       }
    286       case 'error': if (!currentVerified) loaded = false; finish(data.message, true); break;
    287     }
    288   };
    289   current.onerror = event => { if (worker === current) { event.preventDefault(); if (!currentVerified) loaded = false; finish(event.message || 'Worker failed.', true); } };
    290   send({ type: 'load', ...selection });
    291 }
    292 function selectExample(entry: CatalogueEntry): void {
    293   const name = examplePackage(entry);
    294   const latest = name ? packageReleases(releases, name)[0] : undefined;
    295   if (!latest || (latest.demo === entry.id && latest.hash === entry.hash)) select(entry, latest);
    296   else loadBundle(latest);
    297 }
    298 function select(entry: CatalogueEntry, release?: Release): void {
    299   sourceAvailable = false;
    300   draft = false; scratch = false; originalSource = undefined;
    301   revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false;
    302   selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash;
    303   sourceEditor.hidden = true; el('source-notes').hidden = false;
    304   const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id);
    305   chooseProgram(`demo:${entry.id}`, pinned?.version);
    306   showRelease(pinned);
    307   setPreset(entry); el('source').textContent = 'Verifying package…';
    308   newWorker(false);
    309 }
    310 function invalidateProgram(): void {
    311   el('bundle-download-status').textContent = '';
    312   sourceAvailable = false;
    313   draft = false; scratch = false; originalSource = undefined;
    314   revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined;
    315   exportsInput.replaceChildren();
    316   sourceEditor.hidden = true; el('source-notes').hidden = false;
    317   el('source').textContent = 'Load the selected program to inspect its source or package notes.';
    318   el('package-info').textContent = 'No bundle loaded.'; showRelease();
    319   controls(false); el('status').textContent = 'Load package';
    320 }
    321 function loadBundle(release?: Release): void {
    322   const value = release?.hash ?? hashInput.value;
    323   invalidateProgram();
    324   chooseProgram(release ? releaseProgram(release) : 'hash', release?.version);
    325   hashInput.value = value;
    326   try {
    327     const hash = bundleHash(value); selection = { hash };
    328     argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure';
    329     showRelease(release); el('source').textContent = 'Verifying registry package…'; newWorker(false);
    330   } catch (error) { operation = 'loading'; finish(error instanceof Error ? error.message : String(error), true); }
    331 }
    332 el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); };
    333 hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); };
    334 exportsInput.onchange = () => { markPreviousResult(); preferredName = exportsInput.value; clearTreeExport(); if (storedOutput()) return; if (!scratch && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); else presetExpression(); };
    335 runButton.onclick = () => {
    336   if (busy || runButton.disabled) return;
    337   revealOutput.arm();
    338   if (storedOutput()) {
    339     if (draft || !loaded || !selection) return;
    340     const request: Extract<ClientMessage, { type: 'export' }> = { type: 'export', export: exportsInput.value, format: treeFormat(resultCodec.value) };
    341     if (worker) startExport(request, value => worker?.postMessage(value));
    342     else newWorker(false, true, request);
    343     return;
    344   }
    345   if (draft) { runDraft(); return; }
    346   if (!loaded || !selection) return;
    347   operation = 'running'; clearOutput();
    348   try {
    349     const args = guidedInputs.checked ? argumentsUI.read() : [], output: OutputType = outputType(resultCodec.value);
    350     savedRun = { type: 'run', ...expressionInput(), export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked };
    351       if (worker) { controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage(savedRun); }
    352     else newWorker(true);
    353   } catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
    354 };
    355 function resume(value?: string): void {
    356   if (!worker || !pending) return;
    357   const message: ClientMessage = value === undefined ? { type: 'resume', id: pending.id } : { type: 'resume', id: pending.id, value };
    358   pending = undefined; inputForm.hidden = true; nextButton.disabled = true; el('status').textContent = 'Running'; clock(); worker.postMessage(message);
    359 }
    360 inputForm.onsubmit = event => {
    361   event.preventDefault(); if (pending?.kind !== 'input') return;
    362   try { line(new Graph({ nodes: 16_000, fuel: 100_000 }), lineInput.value); el('result').textContent = ''; el('result').className = ''; resume(lineInput.value); }
    363   catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; }
    364 };
    365 nextButton.onclick = () => { if (pending?.kind === 'step') resume(); };
    366 stopButton.onclick = () => {
    367   imports.cancel(); revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); };
    368 el<HTMLButtonElement>('stop-run').onclick = () => stopButton.click();
    369 el<HTMLButtonElement>('reset').onclick = () => { if (busy) return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; };
    370 shareButton.onclick = async () => {
    371   if (draft || !selection) return;
    372   const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value });
    373   if (selection.localId) fragment.set('demo', selection.localId);
    374   url.hash = fragment.toString();
    375   try { await navigator.clipboard.writeText(url.href); el('bundle-download-status').textContent = 'Copied a link to this package and export.'; }
    376   catch { el('bundle-download-status').textContent = url.href; }
    377 };
    378 window.addEventListener('pagehide', () => dispose());
    379 // Associate an example with its published name only through the catalogue's exact pin.
    380 function examplePackage(entry: CatalogueEntry): string | undefined {
    381   return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name;
    382 }
    383 function programPackage(value: string): string | undefined {
    384   if (value.startsWith('package:')) return value.slice(8);
    385   const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined;
    386   return entry ? examplePackage(entry) : undefined;
    387 }
    388 function releaseProgram(release: Release): string {
    389   const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name);
    390   return entry ? `demo:${entry.id}` : `package:${release.name}`;
    391 }
    392 function chooseProgram(value: string, version?: string): void {
    393   el('bundle-download-status').textContent = '';
    394   programPicker.value = value;
    395   const name = programPackage(value);
    396   el('program-release').hidden = name === undefined;
    397   el('package-toolbar').hidden = name === undefined && value !== 'hash';
    398   el('bundle-hash-help').hidden = value !== 'hash';
    399   el('program-hash').hidden = value !== 'hash';
    400   if (name && versionPackage !== name) {
    401     versionPackage = name;
    402     versionPicker.replaceChildren();
    403     for (const release of packageReleases(releases, name)) {
    404       const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versionPicker.append(option);
    405     }
    406   }
    407   if (name) versionPicker.value = version ?? packageReleases(releases, name)[0]!.version;
    408 }
    409 function populatePrograms(): void {
    410   programPicker.replaceChildren();
    411   const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.textContent = 'Choose a program'; programPicker.append(placeholder);
    412   for (const category of exampleGroups) {
    413     const group = document.createElement('optgroup'); group.label = category;
    414     for (const entry of entries.filter(entry => exampleGroup(entry) === category)) {
    415       const option = document.createElement('option'); option.value = `demo:${entry.id}`; option.textContent = entry.title; group.append(option);
    416     }
    417     if (group.children.length) programPicker.append(group);
    418   }
    419   const packages = document.createElement('optgroup'); packages.label = 'Libraries';
    420   for (const release of selectorLibraries(releases)) {
    421     const option = document.createElement('option'); option.value = `package:${release.name}`;
    422     option.textContent = libraryLabel(release.name); option.title = release.title; packages.append(option);
    423   }
    424   const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…';
    425   programPicker.append(packages, hash); programPicker.disabled = false;
    426 }
    427 function showRelease(release?: Release): void {
    428   el('module-import-help').hidden = release === undefined;
    429   el('module-import-source').textContent = release ? `!import ${release.manifest} as Library` : '';
    430   el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'No named release selected. Direct bundle hashes pin the whole file.';
    431 }
    432 programPicker.onchange = () => {
    433   const value = programPicker.value;
    434   if (value.startsWith('demo:')) {
    435     const entry = entries.find(e => e.id === value.slice(5)); if (entry) selectExample(entry);
    436   } else {
    437     chooseProgram(value); if (value === 'hash') hashInput.value = ''; invalidateProgram();
    438     if (value.startsWith('package:')) loadSelectedRelease();
    439     else if (value === 'hash') hashInput.focus();
    440   }
    441 };
    442 versionPicker.onchange = invalidateProgram;
    443 el<HTMLButtonElement>('load-release').onclick = loadSelectedRelease;
    444 function loadSelectedRelease(): void {
    445   const name = programPackage(programPicker.value);
    446   const release = releases.find(r => r.name === name && r.version === versionPicker.value); if (!release) return;
    447   const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
    448   if (demo) select(demo, release);
    449   else loadBundle(release);
    450 }
    451 function downloadHash(): BundleHash | undefined {
    452   const name = programPackage(programPicker.value);
    453   if (name) return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash;
    454   if (programPicker.value === 'hash') {
    455     try { return bundleHash(hashInput.value); } catch { return undefined; }
    456   }
    457   return undefined;
    458 }
    459 downloadButton.onclick = () => {
    460   const hash = downloadHash(); if (busy || draft || !hash) return;
    461   // Download has its own verified target, not the loaded executable selection.
    462   // It never changes source, definitions, arguments or the Run/Export target.
    463   dispose(); operation = 'downloading';
    464   const current = new Worker(workerURL(), { type: 'module' }); worker = current;
    465   controls(true); el('status').textContent = 'Downloading bundle';
    466   el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error');
    467   deadline.reset(PACKAGE_LOAD_MS); clock();
    468   current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => {
    469     if (worker !== current) return;
    470     if (data.type === 'loaded') {
    471       if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; }
    472       current.postMessage({ type: 'download' });
    473     } else if (data.type === 'downloaded') {
    474       if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; }
    475       const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' }));
    476       const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${hash}.arboricx`;
    477       document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);
    478       const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`;
    479       dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package';
    480       el('bundle-download-status').textContent = message;
    481     } else if (data.type === 'error') finish(data.message, true);
    482   };
    483   current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); };
    484   current.postMessage({ type: 'load', hash });
    485 };
    486 resultCodec.onchange = markPreviousResult;
    487 argsInput.addEventListener('input', markPreviousResult);
    488 el('argument-fields').addEventListener('input', markPreviousResult);
    489 mode.onchange = () => { markPreviousResult(); el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; };
    490 expressionEditor.oninput = markPreviousResult;
    491 guidedInputs.onchange = () => {
    492   if (!guidedInputs.checked) {
    493     try { presetExpression(); }
    494     catch (error) { guidedInputs.checked = true; outputUI.clear(); el('result').textContent = String(error); }
    495   }
    496   markPreviousResult(); controls(busy);
    497 };
    498 sourceEditor.addEventListener('compositionstart', () => { composing = true; cancelIdleCompile(); });
    499 sourceEditor.addEventListener('compositionend', () => { composing = false; if (draft) queueCompile(); });
    500 sourceEditor.oninput = () => {
    501   if (busy && operation !== 'compiling') return;
    502   editRevision++; markPreviousResult(); el('compile-diagnostic').hidden = true;
    503   if (operation === 'compiling') { draft = true; controls(true); queueCompile(); return; }
    504   preferredName = exportsInput.value || preferredName;
    505   keepDraft(); clearTreeExport();
    506   if (!compiled) exportsInput.replaceChildren();
    507   draft = compiled || scratch || sourceEditor.value !== originalSource;
    508   controls(false);
    509   if (!draft && selection) { preferredExport = preferredName; newWorker(false, true); }
    510   else { el('status').textContent = 'Changes pending'; queueCompile(); }
    511 };
    512 restoreButton.onclick = () => {
    513   if (originalSource === undefined) return;
    514   dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); controls(busy);
    515 };
    516 compileButton.onclick = () => compileDraft();
    517 function compileDraft(): void {
    518   if (!draft || busy) return;
    519   const snapshot = { revision: editRevision, source: sourceEditor.value };
    520   dispose(); operation = 'compiling'; markPreviousResult(); clearTreeExport();
    521   el('compile-diagnostic').hidden = true;
    522   try {
    523     sourceText(sourceEditor.value);
    524     const current = draftWorker(snapshot);
    525     controls(true); deadline.reset(Infinity); clock(); el('status').textContent = 'Compiling';
    526     el('compiler-stats').textContent = 'Loading pinned compiler and source dependencies…';
    527     const started = performance.now();
    528     const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; };
    529     activity(); compilerTimer = setInterval(activity, 1000);
    530     current.postMessage({ type: 'compile', source: snapshot.source, localModules: localModules.inputs(),
    531       ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) });
    532   } catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
    533 }
    534 function draftWorker(snapshot: { revision: number; source: string }): Worker {
    535   const current = new Worker(workerURL(), { type: 'module' }); worker = current;
    536   const discardStale = (): boolean => {
    537     if (operation !== 'compiling' || snapshot.revision === editRevision) return false;
    538     dispose(); el('status').textContent = 'Changes pending'; queueCompile(Math.max(0, idleDue - performance.now())); return true;
    539   };
    540   current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => {
    541     if (worker !== current) return;
    542     if ((data.type === 'compiled' || data.type === 'error') && discardStale()) return;
    543     switch (data.type) {
    544       case 'module-exported': {
    545         idleDraft();
    546         const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes).buffer], { type: 'application/octet-stream' }));
    547         const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`;
    548         anchor.click(); URL.revokeObjectURL(url);
    549         el('compiled-module-status').textContent = `Exported compiled module. Manifest: ${data.manifest} · Bundle SHA-256: ${data.hash}. Open the downloaded file under Local modules to import it.`;
    550         el('status').textContent = 'Compiled'; break;
    551       }
    552       case 'module-export-error':
    553         idleDraft(); el('compiled-module-status').textContent = data.message; el('status').textContent = 'Module export failed'; break;
    554       case 'compiled':
    555         stopCompilerActivity(); pauseClock(); operation = undefined; compiledSource = snapshot.source; compiledRevision = snapshot.revision; compiled = true;
    556         exportsInput.replaceChildren();
    557         for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); }
    558         if (data.names.includes(preferredName)) exportsInput.value = preferredName;
    559         idleDraft(); el('status').textContent = 'Compiled';
    560         compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`;
    561         el('compiler-stats').textContent = compilationStats;
    562         break;
    563       case 'state':
    564         if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = operation === 'compiling' ? 'Compiling' : operation === 'exporting' ? 'Exporting module' : 'Running'; }
    565         else { pauseClock(); pending = { id: data.id, kind: data.state }; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); }
    566         break;
    567       case 'trace': {
    568         const row = document.createElement('li'); row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; el('trace').append(row); el('trace-empty').hidden = true; break;
    569       }
    570       case 'output': el('terminal').textContent += data.text; revealOutput.reveal(el('io-console')); break;
    571       case 'done':
    572         idleDraft(); el('status').textContent = 'Finished';
    573         outputUI.result(data.result, `Evaluated local draft · ${backends[selectedBackend].label}`);
    574         revealOutput.reveal(el('result'));
    575         el('compiler-stats').textContent = `${compilationStats} · ${runtimeStats(data.stats)}`;
    576         break;
    577       case 'error':
    578         if (operation === 'running' && !data.fatal) {
    579           idleDraft(); outputUI.clear(); el('status').textContent = 'Stopped';
    580           el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; revealOutput.reveal(el('result'));
    581         } else finish(data.message, true);
    582         break;
    583     }
    584   };
    585   current.onerror = event => { if (worker === current) { event.preventDefault(); if (!discardStale()) finish(event.message || 'Compiler Worker failed.', true); } };
    586   return current;
    587 }
    588 // Definitions exist only in the owning live Worker; stopping discards them.
    589 function idleDraft(): void {
    590   stopCompilerActivity(); pauseClock(); operation = undefined; pending = undefined;
    591   inputForm.hidden = true; nextButton.disabled = true; controls(false);
    592 }
    593 function keepDraft(): void {
    594   if (worker && compiled && !busy) idleDraft();
    595   else dispose();
    596 }
    597 function runDraft(): void {
    598   if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) return;
    599   try {
    600     const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value);
    601     const expression = expressionInput();
    602     if (!worker || busy) return;
    603     operation = 'running'; clearOutput(); controls(true); runClock(); el('status').textContent = 'Running';
    604     const current = worker;
    605     current.postMessage({ type: 'run', ...expression, export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked });
    606   } catch (error) {
    607     outputUI.clear(); el('status').textContent = 'Stopped';
    608     el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; revealOutput.reveal(el('result'));
    609     keepDraft();
    610   }
    611 }
    612 backendPicker.onchange = () => {
    613   selectedBackend = backend(backendPicker.value); editRevision++; dispose(); markPreviousResult();
    614   if (draft) { controls(false); el('status').textContent = 'Changes pending'; queueCompile(0); }
    615   else if (selection) newWorker(false, true);
    616 };
    617 const examples = {
    618   identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
    619   contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' },
    620   pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' },
    621   definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' },
    622   input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' },
    623 };
    624 for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => {
    625   const math = packageReleases(releases, 'tricu-lib-native-math')[0];
    626   const example = button.dataset['compilerExample'] === 'module' && math
    627     ? { name: 'answer', source: `!import ${math.manifest} as N\n!export answer\n\nanswer = N.add 40 2`, args: [], output: 'number', mode: 'pure' }
    628     : examples[button.dataset['compilerExample'] as keyof typeof examples];
    629   if (!example) return;
    630   chooseProgram('');
    631   revealOutput.cancel(); keepDraft(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined;
    632   if (!compiled) exportsInput.replaceChildren();
    633   editRevision++; preferredName = example.name;
    634   sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source;
    635   argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); resultCodec.value = visibleCodec(example.output); mode.value = example.mode; presetExpression(example.name);
    636   controls(false); el('package-info').textContent = 'Scratch program · no published bundle selected.'; showRelease(); el('source-label').textContent = 'Scratch program · explicit modules or contract helpers';
    637   el('status').textContent = 'Changes pending'; queueCompile();
    638 };
    639 function loadFragment(): boolean {
    640   const fragment = new URLSearchParams(location.hash.slice(1));
    641   const requested = fragment.get('bundle');
    642   if (!requested) return false;
    643   const hash = bundleHash(requested), local = entries.find(e => e.id === fragment.get('demo') && e.hash === hash);
    644   preferredExport = fragment.get('export') ?? undefined;
    645   if (local) select(local);
    646   else {
    647     chooseProgram('hash');
    648     showRelease(); draft = false; scratch = false; originalSource = undefined; sourceAvailable = false;
    649     revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined;
    650     argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure';
    651     selection = { hash }; hashInput.value = hash; newWorker(false);
    652   }
    653   return true;
    654 }
    655 window.addEventListener('hashchange', () => {
    656   if (!entries.length) return;
    657   try { if (new URLSearchParams(location.hash.slice(1)).has('bundle')) loadFragment(); }
    658   catch (error) { loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
    659 });
    660 let initialized = false;
    661 async function initializePackages(): Promise<void> {
    662   if (initialized) return;
    663   initialized = true; operation = 'catalogue';
    664   try {
    665     if (!globalThis.Worker || !globalThis.crypto?.subtle) throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).');
    666     const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000);
    667     const input: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
    668     if (!Array.isArray(input) || input.length > 32) throw new Error('Invalid catalogue');
    669     entries = input.map((raw: unknown): CatalogueEntry => {
    670       if (!raw || typeof raw !== 'object') throw new Error('Invalid catalogue entry');
    671       const v = raw as Record<string, unknown>;
    672       for (const key of ['id', 'title', 'source', 'sourceSha256', 'export']) if (typeof v[key] !== 'string') throw new Error('Invalid catalogue field');
    673       const hash = bundleHash(v['hash']); literals(v['arguments']); outputType(v['output']);
    674       if (v['mode'] !== 'pure' && v['mode'] !== 'io') throw new Error('Invalid catalogue mode');
    675       if (v['category'] !== undefined && !exampleGroups.includes(v['category'] as typeof exampleGroups[number])) throw new Error('Invalid example category');
    676       if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128)) throw new Error('Invalid demo environment');
    677       return { ...v, hash } as unknown as CatalogueEntry;
    678     });
    679     const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576);
    680     releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown);
    681     populatePrograms();
    682     if (selection || draft) { operation = undefined; return; }
    683     if (!loadFragment()) { const initial = entries.find(e => e.id === 'names-values') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); selectExample(initial); }
    684   } catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
    685 }
    686 setupInspector();
    687 void initializePackages();