tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

app.ts (34266B)


      1 import { terminalView } from './io-terminal-view.js';
      2 import { SourceEditor } from './source-editor.js';
      3 import { definitionInspector } from './definition-inspector.js';
      4 import { exampleGroups, exampleGroup, libraryLabel } from './program-catalogue.js';
      5 import { importPicker } from './import-picker.js';
      6 import { localModulesUI } from './local-modules-ui.js';
      7 import { tokenize, paintSyntax } from './syntax.js';
      8 import { backends, runtimeStats } from './runtime-backends.js';
      9 import { setupInspector } from './inspector.js';
     10 import { ActiveClock } from './clock.js';
     11 import { PACKAGE_LOAD_MS, operationFailure, operationTimeout, type Operation } from './execution.js';
     12 import { sourceText } from './compiler.js';
     13 import { packageIndex, packageReleases, type Release } from './packages.js';
     14 import { bundleHash, fetchBytes, type BundleHash } from './bundle.js';
     15 import { literals, outputType } from './codecs.js';
     16 import type { CatalogueEntry, WorkerMessage, RequestId } from './protocol.js';
     17 import type { CompilerMessage } from './compiler-protocol.js';
     18 function el<T extends HTMLElement = HTMLElement>(id: string): T {
     19   const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); return element as T;
     20 }
     21 const sourceEditor = new SourceEditor(el('source-editor'));
     22 const compileButton = el<HTMLButtonElement>('compile'), restoreButton = el<HTMLButtonElement>('restore-source');
     23 const downloadButton = el<HTMLButtonElement>('download-bundle'), exportModuleButton = el<HTMLButtonElement>('export-compiled-module');
     24 const programPicker = el<HTMLSelectElement>('program-picker');
     25 const versionPicker = el<HTMLSelectElement>('package-version'), hashInput = el<HTMLInputElement>('bundle-hash');
     26 const stopButton = el<HTMLButtonElement>('stop'), shareButton = el<HTMLButtonElement>('share');
     27 const selectedBackend = 'haskell-wasm';
     28 const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url);
     29 let draft = false, scratch = false, compiled = false, busy = false, loaded = false;
     30 let originalSource: string | undefined, compiledSource: string | undefined, compiledRevision: number | undefined;
     31 let sourceAvailable = false, editRevision = 0, composing = false, idleDue = 0, compilationStats = '';
     32 let entries: CatalogueEntry[] = [], releases: Release[] = [], selected: CatalogueEntry | undefined;
     33 let selection: { hash: BundleHash; localId?: string } | undefined, versionPackage: string | undefined;
     34 let preferredExport: string | undefined, currentName = '', publishedNames: string[] = [];
     35 let worker: Worker | undefined, pending: { id: RequestId } | undefined;
     36 let ioVersion: 0 | 2 | undefined, ioRun: string | undefined, ioWidget: ReturnType<typeof terminalView> | undefined, stoppingIO = false;
     37 let operation: Operation | undefined, idleTimer: ReturnType<typeof setTimeout> | undefined;
     38 let compilerTimer: ReturnType<typeof setInterval> | undefined;
     39 const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true));
     40 function cancelIdleCompile(): void { clearTimeout(idleTimer); idleTimer = undefined; }
     41 function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; }
     42 function queueCompile(delay = 650): void {
     43   cancelIdleCompile(); if (composing) return;
     44   idleDue = performance.now() + delay;
     45   idleTimer = setTimeout(() => { idleTimer = undefined; if (!busy && draft) compileDraft(); }, delay);
     46   stopButton.hidden = false; stopButton.disabled = false;
     47 }
     48 const localModules = localModulesUI(() => {
     49   if (!draft || busy && operation !== 'compiling') return;
     50   editRevision++; definitions.reset();
     51   if (!busy) { dispose(); el('status').textContent = 'Changes pending'; }
     52   queueCompile();
     53 });
     54 const definitions = definitionInspector(sourceEditor, (id, name, output) => {
     55   if (busy || !worker) return;
     56   currentName = name; operation = 'running'; controls(true); deadline.reset(Infinity); deadline.start();
     57   el('status').textContent = `Inspecting ${name}`; diagnostic();
     58   worker.postMessage({ type: 'inspect', id, name, output });
     59 }, {
     60   run(name, output, step) {
     61     if (busy || !worker || draft && (!compiled || sourceEditor.getSource() !== compiledSource || editRevision !== compiledRevision)) return;
     62     currentName = name; operation = 'running'; controls(true); deadline.reset(Infinity); deadline.start(); diagnostic();
     63     ioVersion = 0; stoppingIO = false;
     64     el('status').textContent = `Running IO · ${name}`;
     65     worker.postMessage({ type: 'run-io', name, output, step });
     66   },
     67   stop: () => stopButton.click(),
     68   resume() {
     69     if (!worker || !pending) return;
     70     worker.postMessage({ type: 'resume', id: pending.id });
     71     pending = undefined; deadline.start();
     72   },
     73 });
     74 const imports = importPicker({ source: sourceEditor, backend: () => selectedBackend,
     75   available: () => !busy, editable: () => !busy && !sourceEditor.dom.hidden && !sourceEditor.isReadOnly,
     76   tryLibrary: (library, definition) => { preferredExport = definition; loadBundle(library); },
     77   local: () => localModules.entries(), inputs: () => localModules.inputs() });
     78 function diagnostic(message = ''): void { el('operation-diagnostic').textContent = message; el('operation-diagnostic').hidden = !message; }
     79 function ready(): boolean { return !!worker && (draft ? compiled && sourceEditor.getSource() === compiledSource && editRevision === compiledRevision : loaded); }
     80 function controls(working: boolean): void {
     81   busy = working; localModules.disable(busy);
     82   programPicker.disabled = busy || !entries.length;
     83   versionPicker.disabled = hashInput.disabled = busy;
     84   el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value);
     85   el<HTMLButtonElement>('load').disabled = busy; el<HTMLButtonElement>('add-import').disabled = busy;
     86   compileButton.disabled = busy || !draft || ready(); compileButton.hidden = !draft; restoreButton.hidden = !draft;
     87   restoreButton.disabled = busy || originalSource === undefined;
     88   exportModuleButton.disabled = busy || !draft || !ready(); downloadButton.disabled = busy || draft || downloadHash() === undefined;
     89   sourceEditor.setAvailability(busy && operation !== 'compiling', originalSource === undefined && !draft);
     90   const editable = !sourceEditor.dom.hidden && (draft || originalSource !== undefined);
     91   el('source-title').textContent = editable ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package' : 'Package information';
     92   el('source-availability').hidden = editable || !loaded;
     93   el('source-availability').textContent = 'Browse stored definitions below, or import this module into your own source. Inspection does not execute IO.';
     94   el('package-definitions').hidden = !loaded || !sourceEditor.dom.hidden;
     95   el<HTMLButtonElement>('reload-package').hidden = draft || !selection || !!worker;
     96   el<HTMLButtonElement>('stored-download').disabled = busy || draft || !loaded || !worker;
     97   el<HTMLSelectElement>('stored-definition').disabled = el<HTMLSelectElement>('stored-format').disabled = busy || draft || !loaded;
     98   el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified';
     99   const modified = draft && !ready(); el('authorship').classList.toggle('modified', modified); sourceEditor.dom.classList.toggle('modified', modified);
    100   for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy;
    101   stopButton.disabled = !busy && !draft; stopButton.hidden = !busy; shareButton.disabled = busy || draft || !loaded;
    102   el<HTMLButtonElement>('reset').disabled = busy;
    103   sourceEditor.refresh(); definitions.update(!sourceEditor.dom.hidden && ready() || sourceEditor.dom.hidden && loaded && !!worker, busy, draft ? undefined : publishedNames);
    104   for (const button of el('package-definition-list').querySelectorAll<HTMLButtonElement>('button')) button.disabled = busy || !loaded;
    105 }
    106 function renderPublishedNames(): void {
    107   const query = el<HTMLInputElement>('package-definition-search').value.toLowerCase();
    108   el('package-definition-list').replaceChildren(...publishedNames.filter(name => name.toLowerCase().includes(query)).map(name => {
    109     const button = document.createElement('button'); button.type = 'button'; button.textContent = name; button.disabled = busy || !loaded;
    110     button.onclick = () => { currentName = name; definitions.open(name); }; return button;
    111   }));
    112 }
    113 el('package-definition-search').oninput = renderPublishedNames;
    114 el('reload-package').onclick = () => { if (!busy && !draft && selection) newWorker(true); };
    115 el('stored-download').onclick = () => {
    116   if (busy || draft || !loaded || !worker) return;
    117   operation = 'exporting'; controls(true); deadline.reset(120000); deadline.start();
    118   el('stored-status').textContent = 'Exporting original records…';
    119   worker.postMessage({type:'export',export:el<HTMLSelectElement>('stored-definition').value,format:el<HTMLSelectElement>('stored-format').value});
    120 };
    121 function dispose(): void {
    122   ioWidget?.abandon(); ioWidget = undefined; ioRun = undefined; ioVersion = undefined; stoppingIO = false;
    123   cancelIdleCompile(); stopCompilerActivity(); deadline.pause(); operation = undefined; worker?.terminate(); worker = undefined;
    124   compiled = false; compiledSource = undefined; compiledRevision = undefined; pending = undefined; controls(false);
    125 }
    126 function idle(): void { ioVersion = undefined; stoppingIO = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); }
    127 function finish(message: string, error = false, cancelled = false): void {
    128   const previous = operation;
    129   if (error) message = operationFailure(operation, message);
    130   definitions.stopped(message, cancelled || error && previous === 'running'); dispose(); diagnostic(message);
    131   el('status').textContent = previous === 'compiling' ? 'Compilation failed' : error ? 'Stopped' : 'Finished';
    132   if (previous === 'compiling') { el('compile-diagnostic').textContent = message; el('compile-diagnostic').hidden = false; }
    133   if (previous === 'exporting') el('compiled-module-status').textContent = message;
    134   if (previous === 'downloading') el('bundle-download-status').textContent = message;
    135   if (!loaded && !draft) el('package-info').textContent = `Package not loaded. ${message}`;
    136 }
    137 stopButton.onclick = () => {
    138   if (operation === 'running' && ioVersion !== undefined) {
    139     if (!stoppingIO) { stoppingIO = true; worker?.postMessage({ type: 'io-stop' }); }
    140     stopButton.disabled = true; el('status').textContent = 'Cancelling; waiting for cleanup…'; return;
    141   }
    142   imports.cancel();
    143   finish(draft ? 'Stopped. Compile the draft again to inspect or run it.' : 'Stopped. Reload the package to inspect or run it.', false, true);
    144   el('status').textContent = 'Stopped';
    145 };
    146 el<HTMLButtonElement>('reload-playground').onclick = () => {
    147   if ((draft || localModules.entries().length > 0) && !window.confirm('Reload the Playground and restart all runtimes? Local source edits, results and opened local modules will be cleared. Copy any work you want to keep before continuing.')) return;
    148   location.reload();
    149 };
    150 el<HTMLButtonElement>('reset').onclick = () => { if (!busy) { definitions.reset(); diagnostic(); } };
    151 shareButton.onclick = async () => {
    152   if (draft || !selection) return;
    153   const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: currentName || selected?.export || publishedNames[0] || '' });
    154   if (selection.localId) fragment.set('demo', selection.localId); url.hash = fragment.toString();
    155   try { await navigator.clipboard.writeText(url.href); el('bundle-download-status').textContent = 'Copied a link to this package and definition.'; }
    156   catch { el('bundle-download-status').textContent = url.href; }
    157 };
    158 window.addEventListener('pagehide', () => dispose());
    159 function receive(data: CompilerMessage): void {
    160   if (data.type === 'inspected' || data.type === 'inspection-error') {
    161     if (!definitions.reply(data)) return;
    162     if (data.type === 'inspection-error' && data.fatal) finish(data.message, true);
    163     else { idle(); el('status').textContent = draft ? 'Compiled' : 'Ready'; }
    164     return;
    165   }
    166   definitions.event(data);
    167   switch (data.type) {
    168     case 'io-version': ioVersion = data.version; break;
    169     case 'io-open': {
    170       if (ioWidget) throw new Error('Overlapping terminal runs');
    171       const current = worker; ioRun = data.run;
    172       ioWidget = terminalView(el('terminal-io'), data.run,
    173         reply => { if (worker === current && ioRun === reply.run) current?.postMessage({ type: 'io-command', command: { run: reply.run, kind: 'reply', reply } }); },
    174         () => stopButton.click());
    175       break;
    176     }
    177     case 'io-event': if (data.event.run === ioRun) ioWidget?.handle(data.event); break;
    178     case 'io-close':
    179       if (data.run === ioRun) { ioVersion = undefined; if (data.settled) ioWidget?.finish(); else ioWidget?.abandon(); ioWidget = undefined; ioRun = undefined; el('status').textContent = data.settled ? 'IO settled; interpreting returned value' : 'Runtime lost'; }
    180       break;
    181     case 'state':
    182       if (data.state === 'running') { pending = undefined; deadline.start(); if (operation === 'running' && !stoppingIO) el('status').textContent = `Running IO · ${currentName}`; }
    183       else { deadline.pause(); pending = {id:data.id}; el('status').textContent = 'Paused before action'; }
    184       break;
    185     case 'done': idle(); el('status').textContent = 'Finished'; el('compiler-stats').textContent = `${compilationStats} · IO · ${runtimeStats(data.stats)}`; break;
    186     case 'error':
    187       if (operation === 'running' && !data.fatal) { definitions.stopped(data.message); idle(); diagnostic(data.message); el('status').textContent = 'Stopped'; }
    188       else finish(data.message, true);
    189       break;
    190     case 'exported': {
    191       if (draft || data.hash !== selection?.hash || data.export !== el<HTMLSelectElement>('stored-definition').value || data.format !== el<HTMLSelectElement>('stored-format').value) return;
    192       const url = URL.createObjectURL(new Blob([data.file.text], {type:'text/plain;charset=utf-8'}));
    193       const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.export.replace(/[^a-zA-Z0-9_-]/g,'-').slice(0,64)}.${data.file.extension}`;
    194       document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000);
    195       idle(); el('stored-status').textContent = `Downloaded stored definition ${data.export} · SHA-256 ${data.hash}`; el('status').textContent = 'Ready'; break;
    196     }
    197     case 'export-error': idle(); el('stored-status').textContent = data.message; el('status').textContent = 'Export failed'; break;
    198     case 'module-exported': {
    199       idle(); const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes).buffer], {type:'application/vnd.arboricx.bundle'}));
    200       const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`; document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000);
    201       el('compiled-module-status').textContent = `Exported compiled module. Manifest: ${data.manifest} · Bundle SHA-256: ${data.hash}.`;
    202       el('status').textContent = 'Compiled'; break;
    203     }
    204     case 'module-export-error': idle(); el('compiled-module-status').textContent = data.message; el('status').textContent = 'Module export failed'; break;
    205   }
    206 }
    207 function newWorker(preserveSource = false): void {
    208   if (!selection) return;
    209   dispose(); loaded = false; operation = 'loading'; diagnostic(); deadline.reset(PACKAGE_LOAD_MS); deadline.start();
    210   const current = new Worker(workerURL(), {type:'module'}); worker = current;
    211   controls(true); el('status').textContent = 'Loading package'; el('package-info').textContent = 'Verifying package…';
    212   current.onmessage = ({data}: MessageEvent<WorkerMessage>) => {
    213     if (worker !== current) return;
    214     if (data.type !== 'loaded') { receive(data as CompilerMessage); return; }
    215     loaded = true; publishedNames = data.exports.map(item=>item.name);
    216     const requested = preferredExport; currentName = requested && publishedNames.includes(requested) ? requested : selected?.export ?? publishedNames[0] ?? ''; preferredExport = undefined;
    217     el<HTMLSelectElement>('stored-definition').replaceChildren(...publishedNames.map(name=>new Option(name,name)));
    218     if (publishedNames.includes(currentName)) el<HTMLSelectElement>('stored-definition').value = currentName;
    219     el('compiler-stats').textContent = 'Published package · no local compilation.';
    220     el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${publishedNames.length} export(s) · ${data.hash}`;
    221     if (!preserveSource || originalSource === undefined) { originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; sourceEditor.replaceSource(originalSource ?? ''); }
    222     sourceEditor.dom.hidden = originalSource === undefined; el('source-notes').hidden = !sourceEditor.dom.hidden;
    223     sourceAvailable = data.source !== null; el('source-label').textContent = sourceAvailable ? 'Curated tricu / stable' : 'Package metadata';
    224     el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata,null,2)}`;
    225     if (data.source !== null) paintSyntax(el('source'), tokenize(data.source));
    226     el<HTMLInputElement>('package-definition-search').value = sourceEditor.dom.hidden && requested && publishedNames.includes(requested) ? requested : ''; renderPublishedNames(); idle(); el('status').textContent = 'Ready';
    227   };
    228   current.onerror = event => { if (worker === current) { event.preventDefault(); loaded = false; finish(event.message || 'Worker failed.',true); } };
    229   current.postMessage({type:'load',...selection});
    230 }
    231 function selectExample(entry: CatalogueEntry): void {
    232   const name = examplePackage(entry), latest = name ? packageReleases(releases,name)[0] : undefined;
    233   if (!latest || latest.demo === entry.id && latest.hash === entry.hash) select(entry,latest); else loadBundle(latest);
    234 }
    235 function select(entry: CatalogueEntry, release?: Release): void {
    236   definitions.reset(); draft = scratch = false; originalSource = undefined; sourceAvailable = false;
    237   dispose(); diagnostic(); loaded = false; selected = entry; publishedNames = [];
    238   selection = {hash:entry.hash,localId:entry.id}; hashInput.value = entry.hash; sourceEditor.dom.hidden = true; el('source-notes').hidden = false;
    239   const pinned = release ?? releases.find(r=>r.hash===entry.hash&&r.demo===entry.id); chooseProgram(`demo:${entry.id}`,pinned?.version); showRelease(pinned); newWorker();
    240 }
    241 function invalidateProgram(): void {
    242   definitions.reset(); draft = scratch = false; originalSource = undefined; sourceAvailable = false;
    243   dispose(); diagnostic(); loaded = false; selection = undefined; selected = undefined; publishedNames = [];
    244   sourceEditor.dom.hidden = true; el('source-notes').hidden = false; el('source').textContent = 'Load the selected program to inspect its source or package notes.';
    245   el('package-info').textContent = 'No bundle loaded.'; showRelease(); renderPublishedNames(); controls(false); el('status').textContent = 'Load package';
    246 }
    247 function loadBundle(release?: Release): void {
    248   const value = release?.hash ?? hashInput.value, requested = preferredExport;
    249   invalidateProgram(); preferredExport = requested; chooseProgram(release ? releaseProgram(release) : 'hash',release?.version); hashInput.value = value;
    250   try { selection = {hash:bundleHash(value)}; showRelease(release); newWorker(); } catch(error) { finish(String(error),true); }
    251 }
    252 el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); };
    253 hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); };
    254 // Associate an example with its published name only through the catalogue's exact pin.
    255 function examplePackage(entry: CatalogueEntry): string | undefined {
    256   return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name;
    257 }
    258 function programPackage(value: string): string | undefined {
    259   if (value.startsWith('package:')) return value.slice(8);
    260   const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined;
    261   return entry ? examplePackage(entry) : undefined;
    262 }
    263 function releaseProgram(release: Release): string {
    264   const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name);
    265   return entry ? `demo:${entry.id}` : `package:${release.name}`;
    266 }
    267 function chooseProgram(value: string, version?: string): void {
    268   el('bundle-download-status').textContent = '';
    269   if (value.startsWith('package:') && ![...programPicker.options].some(option => option.value === value)) {
    270     for (const group of programPicker.querySelectorAll('optgroup')) if (group.label === 'Exploring library') group.remove();
    271     const group = document.createElement('optgroup'); group.label = 'Exploring library';
    272     group.append(new Option(libraryLabel(value.slice(8)), value)); programPicker.append(group);
    273   }
    274   programPicker.value = value;
    275   const name = programPackage(value);
    276   el('program-release').hidden = name === undefined;
    277   el('package-toolbar').hidden = name === undefined && value !== 'hash';
    278   el('bundle-hash-help').hidden = value !== 'hash';
    279   el('program-hash').hidden = value !== 'hash';
    280   if (name && versionPackage !== name) {
    281     versionPackage = name;
    282     versionPicker.replaceChildren();
    283     for (const release of packageReleases(releases, name)) {
    284       const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versionPicker.append(option);
    285     }
    286   }
    287   if (name) versionPicker.value = version ?? packageReleases(releases, name)[0]!.version;
    288 }
    289 function populatePrograms(): void {
    290   programPicker.replaceChildren();
    291   const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.textContent = 'Choose a program'; programPicker.append(placeholder);
    292   for (const category of exampleGroups) {
    293     const group = document.createElement('optgroup'); group.label = category;
    294     for (const entry of entries.filter(entry => exampleGroup(entry) === category)) {
    295       const option = document.createElement('option'); option.value = `demo:${entry.id}`; option.textContent = entry.title; group.append(option);
    296     }
    297     if (group.children.length) programPicker.append(group);
    298   }
    299   const packages = document.createElement('optgroup'); packages.label = 'Exploring library';
    300   const currentName = selection && releases.find(r => r.hash === selection?.hash && !r.demo)?.name;
    301   const currentRelease = releases.find(r => r.name === currentName);
    302   if (currentRelease) {
    303     const option = new Option(libraryLabel(currentRelease.name), `package:${currentRelease.name}`); packages.append(option);
    304   }
    305   const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…';
    306   if (packages.children.length) programPicker.append(packages);
    307   programPicker.append(hash); programPicker.disabled = false;
    308 }
    309 function showRelease(release?: Release): void {
    310   el('module-import-help').hidden = release === undefined;
    311   el('module-import-source').textContent = release ? `!import ${release.manifest} as Library` : '';
    312   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.';
    313 }
    314 programPicker.onchange = () => {
    315   const value = programPicker.value;
    316   if (value.startsWith('demo:')) {
    317     const entry = entries.find(e => e.id === value.slice(5)); if (entry) selectExample(entry);
    318   } else {
    319     chooseProgram(value); if (value === 'hash') hashInput.value = ''; invalidateProgram();
    320     if (value.startsWith('package:')) loadSelectedRelease();
    321     else if (value === 'hash') hashInput.focus();
    322   }
    323 };
    324 versionPicker.onchange = invalidateProgram;
    325 el<HTMLButtonElement>('load-release').onclick = loadSelectedRelease;
    326 function loadSelectedRelease(): void {
    327   const name = programPackage(programPicker.value);
    328   const release = releases.find(r => r.name === name && r.version === versionPicker.value); if (!release) return;
    329   const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
    330   if (demo) select(demo, release);
    331   else loadBundle(release);
    332 }
    333 function downloadHash(): BundleHash | undefined {
    334   const name = programPackage(programPicker.value);
    335   if (name) return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash;
    336   if (programPicker.value === 'hash') {
    337     try { return bundleHash(hashInput.value); } catch { return undefined; }
    338   }
    339   return undefined;
    340 }
    341 downloadButton.onclick = () => {
    342   const hash = downloadHash(); if (busy || draft || !hash) return;
    343   // Download has its own verified target, not the loaded executable selection.
    344   // It never changes source, definitions, arguments or the Run/Export target.
    345   dispose(); operation = 'downloading';
    346   const current = new Worker(workerURL(), { type: 'module' }); worker = current;
    347   controls(true); el('status').textContent = 'Downloading bundle';
    348   el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error');
    349   deadline.reset(PACKAGE_LOAD_MS); deadline.start();
    350   current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => {
    351     if (worker !== current) return;
    352     if (data.type === 'loaded') {
    353       if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; }
    354       current.postMessage({ type: 'download' });
    355     } else if (data.type === 'downloaded') {
    356       if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; }
    357       const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' }));
    358       const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${hash}.arboricx`;
    359       document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);
    360       const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`;
    361       dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package';
    362       el('bundle-download-status').textContent = message;
    363     } else if (data.type === 'error') finish(data.message, true);
    364   };
    365   current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); };
    366   current.postMessage({ type: 'load', hash });
    367 };
    368 
    369 sourceEditor.dom.addEventListener('compositionstart',()=>{composing=true;cancelIdleCompile();});
    370 sourceEditor.dom.addEventListener('compositionend',()=>{composing=false;if(draft)queueCompile();});
    371 sourceEditor.onChange(() => {
    372   if (busy && operation !== 'compiling') return;
    373   editRevision++; diagnostic(); el('compile-diagnostic').hidden=true;
    374   if (operation === 'compiling') { draft=true;controls(true);queueCompile();return; }
    375   if (!worker || !compiled || busy) dispose();
    376   draft = compiled || scratch || sourceEditor.getSource() !== originalSource;
    377   controls(false);
    378   if (!draft && selection) newWorker(true); else {el('status').textContent='Changes pending';queueCompile();}
    379 });
    380 restoreButton.onclick=()=>{if(originalSource===undefined||busy)return;dispose();sourceEditor.replaceSource(originalSource,true);};
    381 compileButton.onclick=()=>compileDraft();
    382 function compileDraft(): void {
    383   if(!draft||busy)return;
    384   const snapshot={revision:editRevision,source:sourceEditor.getSource()};dispose();operation='compiling';diagnostic();el('compile-diagnostic').hidden=true;
    385   try {
    386     sourceText(snapshot.source);const current=new Worker(workerURL(),{type:'module'});worker=current;
    387     controls(true);deadline.reset(Infinity);deadline.start();el('status').textContent='Compiling';
    388     el('compiler-stats').textContent='Loading pinned compiler and source dependencies…';const started=performance.now();
    389     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`;};activity();compilerTimer=setInterval(activity,1000);
    390     current.onmessage=({data}:MessageEvent<CompilerMessage>)=>{
    391       if(worker!==current)return;
    392       if((data.type==='compiled'||data.type==='error')&&operation==='compiling'&&snapshot.revision!==editRevision){dispose();el('status').textContent='Changes pending';queueCompile(Math.max(0,idleDue-performance.now()));return;}
    393       if(data.type==='compiled') {compiledSource=snapshot.source;compiledRevision=snapshot.revision;compiled=true;idle();el('status').textContent='Compiled';compilationStats=`${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`;el('compiler-stats').textContent=compilationStats;}
    394       else receive(data);
    395     };
    396     current.onerror=event=>{if(worker!==current)return;event.preventDefault();if(snapshot.revision!==editRevision&&operation==='compiling'){dispose();queueCompile(0);}else finish(event.message||'Compiler Worker failed.',true);};
    397     current.postMessage({type:'compile',source:snapshot.source,localModules:localModules.inputs(),...(!scratch&&selected?.environment!==undefined?{environment:selected.environment}:{})});
    398   }catch(error){finish(String(error),true);}
    399 }
    400 exportModuleButton.onclick=()=>{if(busy||!draft||!ready()||!worker)return;operation='exporting';controls(true);deadline.reset(Infinity);deadline.start();el('status').textContent='Exporting module';el('compiled-module-status').textContent='Observing public trees; Stop is available…';worker.postMessage({type:'export-module'});};
    401 const examples={
    402   identity:'id = (x : x)\nanswer = id 42',
    403   contracts:'id x =@nat? x\nanswer = id 42',
    404   pair:'pair a b = t a b\nanswer = pair 7 "hi"',
    405   definitions:'id = (x : x)\nanswer = id 42',
    406 };
    407 for(const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]'))button.onclick=()=>{
    408   const math=packageReleases(releases,'tricu-lib-native-math')[0];
    409   const choice=button.dataset['compilerExample'];
    410   const io=packageReleases(releases,'tricu-lib-io')[0],text=packageReleases(releases,'tricu-lib-io-text')[0];
    411   const source=choice==='input'&&io&&text?`!import ${io.manifest} as IO
    412 !import ${text.manifest} as Text
    413 !export io
    414 io = IO.io <| IO.require IO.stdin (reader :
    415   IO.require (Text.readLine (Text.lineCursor reader) 4096) (reply : case reply of
    416     [["line" value _] _] = IO.pure value
    417     [["eof"] _] = IO.pure ""
    418     _ = IO.fail "Invalid line reply"))`
    419     :choice==='module'&&math?`!import ${math.manifest} as N\n!export answer\nanswer = N.add 40 2`:examples[choice as keyof typeof examples];
    420   if(!source)return;
    421   definitions.reset();dispose();diagnostic();chooseProgram('');scratch=draft=true;originalSource=undefined;loaded=false;selection=undefined;selected=undefined;editRevision++;
    422   sourceEditor.dom.hidden=false;el('source-notes').hidden=true;sourceEditor.replaceSource(source);controls(false);el('package-info').textContent='Scratch program · no published bundle selected.';showRelease();el('source-label').textContent='Scratch program · explicit modules or contract helpers';el('status').textContent='Changes pending';queueCompile();
    423 };
    424 function loadFragment(): boolean {
    425   const fragment=new URLSearchParams(location.hash.slice(1)),requested=fragment.get('bundle');if(!requested)return false;
    426   const hash=bundleHash(requested),local=entries.find(e=>e.id===fragment.get('demo')&&e.hash===hash);preferredExport=fragment.get('export')??undefined;
    427   if(local)select(local);else{hashInput.value=hash;loadBundle();}return true;
    428 }
    429 window.addEventListener('hashchange',()=>{if(!entries.length)return;try{if(new URLSearchParams(location.hash.slice(1)).has('bundle'))loadFragment();}catch(error){finish(String(error),true);}});
    430 let initialized = false;
    431 async function initializePackages(): Promise<void> {
    432   if (initialized) return;
    433   initialized = true; operation = 'catalogue';
    434   try {
    435     if (!globalThis.Worker || !globalThis.crypto?.subtle) throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).');
    436     const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000);
    437     const input: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
    438     if (!Array.isArray(input) || input.length > 32) throw new Error('Invalid catalogue');
    439     entries = input.map((raw: unknown): CatalogueEntry => {
    440       if (!raw || typeof raw !== 'object') throw new Error('Invalid catalogue entry');
    441       const v = raw as Record<string, unknown>;
    442       for (const key of ['id', 'title', 'source', 'sourceSha256', 'export']) if (typeof v[key] !== 'string') throw new Error('Invalid catalogue field');
    443       const hash = bundleHash(v['hash']); literals(v['arguments']); outputType(v['output']);
    444       if (v['mode'] !== 'pure' && v['mode'] !== 'io') throw new Error('Invalid catalogue mode');
    445       if (v['category'] !== undefined && !exampleGroups.includes(v['category'] as typeof exampleGroups[number])) throw new Error('Invalid example category');
    446       if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128)) throw new Error('Invalid demo environment');
    447       return { ...v, hash } as unknown as CatalogueEntry;
    448     });
    449     const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576);
    450     releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown);
    451     populatePrograms();
    452     if (selection || draft) { operation = undefined; return; }
    453     if (!loadFragment()) { const initial = entries.find(e => e.id === 'names-values') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); selectExample(initial); }
    454   } catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
    455 }
    456 setupInspector();
    457 void initializePackages();