app.js (49371B)
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 } 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 } from './execution.js'; 16 import { sourceText } from './compiler.js'; 17 import { packageIndex, packageReleases } from './packages.js'; 18 import { bundleHash, fetchBytes } from './bundle.js'; 19 import { Graph } from './graph.js'; 20 import { line, literals, outputType } from './codecs.js'; 21 function el(id) { 22 const element = document.getElementById(id); 23 if (!element) 24 throw new Error(`Missing element ${id}`); 25 return element; 26 } 27 const sourceEditor = el('source-editor'), compileButton = el('compile'); 28 const restoreButton = el('restore-source'), downloadButton = el('download-bundle'); 29 let sourceAvailable = false; 30 const expressionEditor = el('expression-editor'); 31 const refreshSourceSyntax = syntaxEditor(sourceEditor), refreshExpressionSyntax = syntaxEditor(expressionEditor); 32 const revealOutput = outputReveal(document.querySelector('.editor-toolbar')); 33 const guidedInputs = el('use-guided-inputs'); 34 let editRevision = 0, composing = false, idleDue = 0; 35 let idleTimer; 36 function cancelIdleCompile() { clearTimeout(idleTimer); idleTimer = undefined; } 37 function queueCompile(delay = 650) { 38 cancelIdleCompile(); 39 if (composing) 40 return; 41 idleDue = performance.now() + delay; 42 idleTimer = setTimeout(() => { 43 idleTimer = undefined; 44 if (!busy && draft) 45 compileDraft(); 46 }, delay); 47 } 48 function markPreviousResult() { 49 revealOutput.cancel(); 50 if (el('result').textContent || el('terminal').textContent) 51 el('result-stale').hidden = false; 52 } 53 function presetExpression(name = exportsInput.value || preferredName) { 54 expressionEditor.value = [name, ...argumentsUI.read().map(printLiteral)].join(' '); 55 refreshExpressionSyntax(); 56 } 57 function expressionInput() { 58 return guidedInputs.checked ? {} : { expression: sourceText(expressionEditor.value) }; 59 } 60 const backendPicker = el('runtime-backend'); 61 let selectedBackend = backend(backendPicker.value); 62 const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url); 63 let draft = false, scratch = false, compiled = false, busy = false, originalSource; 64 let compiledSource, compiledRevision; 65 let compilationStats = '', preferredName = 'id'; 66 let compilerTimer; 67 function stopCompilerActivity() { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } 68 let releases = []; 69 const programPicker = el('program-picker'); 70 const versionPicker = el('package-version'); 71 let versionPackage; 72 const hashInput = el('bundle-hash'), exportsInput = el('export'); 73 const argsInput = el('arguments'), resultCodec = el('output-type'); 74 const argumentsUI = argumentEditor(argsInput); 75 const mode = el('mode'), steps = el('step-mode'); 76 const runButton = el('run'), stopButton = el('stop'), nextButton = el('next'); 77 let operation; 78 const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); 79 const shareButton = el('share'); 80 const outputUI = createOutput(); 81 const outputTarget = el('output-target'); 82 const storedOutput = () => outputTarget.value === 'definition'; 83 const visibleCodec = (value) => value === 'tree' || value === 'list' ? 'tricu' : value; 84 let resultInterpretation = 'tricu'; 85 function clearTreeExport() { if (storedOutput()) 86 outputUI.clear(); } 87 function syncOutputTarget() { 88 for (const option of resultCodec.options) 89 option.disabled = storedOutput() && !isTreeResult(option.value); 90 if (storedOutput() && !isTreeResult(resultCodec.value)) 91 resultCodec.value = 'tricu'; 92 controls(busy); 93 } 94 outputTarget.onchange = () => { 95 if (busy) 96 return; 97 revealOutput.cancel(); 98 if (storedOutput()) 99 resultInterpretation = resultCodec.value; 100 else 101 resultCodec.value = resultInterpretation; 102 clearOutput(); 103 syncOutputTarget(); 104 }; 105 const inputForm = el('input-form'), lineInput = el('line-input'); 106 let entries = [], selected; 107 let selection; 108 let loaded = false, worker, pending; 109 let automaticRun = false; 110 let savedRun; 111 let preferredExport; 112 const traceRows = new Map(); 113 const localModules = localModulesUI(() => { 114 if (!draft) 115 return; 116 if (busy && operation !== 'compiling') 117 return; 118 editRevision++; 119 markPreviousResult(); 120 if (!busy) { 121 dispose(); 122 el('status').textContent = 'Changes pending'; 123 } 124 queueCompile(); 125 }); 126 const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value, 127 available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, 128 local: () => localModules.entries(), inputs: () => localModules.inputs() }); 129 const exportModuleButton = el('export-compiled-module'); 130 exportModuleButton.onclick = () => { 131 if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) 132 return; 133 cancelIdleCompile(); 134 operation = 'exporting'; 135 controls(true); 136 deadline.reset(Infinity); 137 clock(); 138 el('status').textContent = 'Exporting module'; 139 el('compiled-module-status').textContent = 'Observing public trees; Stop is available…'; 140 worker.postMessage({ type: 'export-module' }); 141 }; 142 function pauseClock() { deadline.pause(); } 143 function clock() { deadline.start(); } 144 function runClock() { operation = 'running'; deadline.reset(Infinity); clock(); } 145 function controls(working) { 146 busy = working; 147 localModules.disable(busy); 148 exportModuleButton.disabled = busy || !draft || !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision; 149 backendPicker.disabled = busy; 150 programPicker.disabled = busy || entries.length === 0; 151 versionPicker.disabled = busy; 152 hashInput.disabled = busy; 153 el('load-release').disabled = busy || !programPackage(programPicker.value); 154 el('load').disabled = busy; 155 runButton.disabled = busy || (storedOutput() ? draft || !loaded : draft ? !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision : !loaded); 156 runButton.textContent = storedOutput() ? 'Show definition' : 'Evaluate'; 157 el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); 158 el('draft-run-note').textContent = compiled 159 ? 'Source changed. Evaluation waits for the current revision to compile.' 160 : 'Waiting for compilation. Published programs are never used as a fallback.'; 161 argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); 162 el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; 163 nextButton.hidden = storedOutput() || !steps.checked; 164 runButton.classList.toggle('draft', draft); 165 runButton.classList.toggle('primary', !draft || compiled); 166 compileButton.classList.remove('primary'); 167 compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); 168 compileButton.hidden = !draft; 169 restoreButton.hidden = !draft; 170 sourceEditor.disabled = busy && operation !== 'compiling'; 171 sourceEditor.readOnly = originalSource === undefined && !draft; 172 const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); 173 el('add-import').hidden = !editableSource; 174 el('add-import').disabled = busy; 175 el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; 176 el('source-availability').hidden = editableSource || !loaded; 177 el('source-availability').textContent = sourceAvailable 178 ? 'Source is shown for reference only; the published implementation cannot be edited here. Evaluate expressions against its exports below.' 179 : 'This is a compiled package. Editable source is not included; you can evaluate expressions against its exports or export its stored definitions.'; 180 restoreButton.disabled = busy || originalSource === undefined || !draft; 181 downloadButton.disabled = busy || draft || downloadHash() === undefined; 182 el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; 183 const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision); 184 el('authorship').classList.toggle('modified', sourceModified); 185 sourceEditor.classList.toggle('modified', sourceModified); 186 if (draft && storedOutput()) { 187 el('draft-run-note').hidden = false; 188 el('draft-run-note').textContent = 'Stored definitions require a published package. Choose Evaluated result or restore the published source.'; 189 } 190 for (const button of document.querySelectorAll('[data-compiler-example]')) 191 button.disabled = busy; 192 stopButton.disabled = !busy && !draft; 193 stopButton.hidden = !busy; 194 el('stop-run').disabled = stopButton.disabled; 195 exportsInput.disabled = busy || (draft ? !compiled : !loaded); 196 argsInput.disabled = busy; 197 argumentsUI.disable(busy); 198 expressionEditor.disabled = busy && operation !== 'compiling'; 199 guidedInputs.disabled = busy; 200 el('expression-inputs').hidden = storedOutput() || guidedInputs.checked; 201 el('guided-inputs').hidden = storedOutput() || !guidedInputs.checked; 202 mode.disabled = busy || storedOutput(); 203 outputTarget.disabled = busy; 204 resultCodec.disabled = busy; 205 steps.disabled = busy; 206 shareButton.disabled = busy || draft || !loaded; 207 el('reset').disabled = busy; 208 outputUI.controls(busy); 209 refreshSourceSyntax(); 210 refreshExpressionSyntax(); 211 } 212 function dispose() { 213 cancelIdleCompile(); 214 stopCompilerActivity(); 215 pauseClock(); 216 operation = undefined; 217 worker?.terminate(); 218 worker = undefined; 219 compiledSource = undefined; 220 compiledRevision = undefined; 221 compiled = false; 222 pending = undefined; 223 inputForm.hidden = true; 224 nextButton.disabled = true; 225 controls(false); 226 } 227 function clearOutput() { 228 el('result-stale').hidden = true; 229 el('compile-diagnostic').hidden = true; 230 el('terminal').textContent = ''; 231 el('result').textContent = ''; 232 el('result').className = ''; 233 outputUI.clear(); 234 el('trace').replaceChildren(); 235 traceRows.clear(); 236 el('trace-empty').hidden = false; 237 lineInput.value = ''; 238 } 239 function finish(message, error = false, reason = 'failure') { 240 if (error && reason === 'failure') 241 message = operationFailure(operation, message); 242 if (operation === 'exporting' && draft) 243 el('compiled-module-status').textContent = message; 244 if (operation === 'compiling' && reason === 'failure') { 245 dispose(); 246 el('status').textContent = 'Compilation failed'; 247 el('compiler-stats').textContent = 'Compilation failed; runtime discarded.'; 248 el('compile-diagnostic').textContent = message; 249 el('compile-diagnostic').hidden = false; 250 return; 251 } 252 if (!draft && !loaded) 253 el('package-info').textContent = `Package not loaded. ${message}`; 254 if (error && operation !== 'downloading') 255 outputUI.clear(); 256 if (operation === 'downloading') { 257 el('bundle-download-status').textContent = message; 258 el('bundle-download-status').classList.toggle('error', error); 259 } 260 const downloadOnly = operation === 'downloading'; 261 dispose(); 262 el('status').textContent = error ? 'Stopped' : 'Finished'; 263 if (!downloadOnly) { 264 el('result').textContent = message; 265 el('result').className = error ? 'error' : ''; 266 revealOutput.reveal(el('result')); 267 } 268 for (const row of traceRows.values()) 269 row.classList.remove('pending'); 270 } 271 function setPreset(entry, name = entry.export) { 272 const preset = entry.presets?.[name] ?? entry; 273 argumentsUI.labels(demoInputLabels(entry.id, name)); 274 argsInput.value = JSON.stringify(preset.arguments, null, 2); 275 argumentsUI.refresh(true); 276 resultCodec.value = visibleCodec(preset.output); 277 mode.value = preset.mode; 278 presetExpression(name); 279 } 280 function startExport(message, send) { 281 operation = 'exporting'; 282 clearOutput(); 283 controls(true); 284 deadline.reset(EXPORT_ACTIVE_MS); 285 clock(); 286 el('status').textContent = 'Exporting definition'; 287 send(message); 288 } 289 function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { 290 if (!selection) 291 return; 292 dispose(); 293 operation = 'loading'; 294 deadline.reset(PACKAGE_LOAD_MS); 295 automaticRun = runAfterLoad; 296 const current = new Worker(workerURL(), { type: 'module' }); 297 worker = current; 298 let currentVerified = false; 299 controls(true); 300 el('status').textContent = 'Loading package'; 301 el('package-info').textContent = 'Verifying package…'; 302 clock(); 303 const send = (message) => current.postMessage(message); 304 current.onmessage = ({ data }) => { 305 if (worker !== current) 306 return; 307 switch (data.type) { 308 case 'state': 309 if (data.state === 'running') { 310 pending = undefined; 311 clock(); 312 el('status').textContent = operation === 'loading' ? 'Loading package' : 'Running'; 313 } 314 else { 315 pauseClock(); 316 pending = { kind: data.state, id: data.id }; 317 el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; 318 inputForm.hidden = data.state !== 'input'; 319 nextButton.disabled = data.state !== 'step'; 320 revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); 321 } 322 break; 323 case 'loaded': { 324 currentVerified = true; 325 loaded = true; 326 el('compiler-stats').textContent = 'Published package · no local compilation.'; 327 const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export); 328 exportsInput.replaceChildren(); 329 for (const item of data.exports) { 330 const option = document.createElement('option'); 331 option.value = item.name; 332 option.textContent = item.name; 333 exportsInput.append(option); 334 } 335 if (oldExport && data.exports.some(e => e.name === oldExport)) 336 exportsInput.value = oldExport; 337 preferredExport = undefined; 338 el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`; 339 if (!preserveInputs && !automaticRun && !exportAfterLoad) { 340 originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; 341 sourceEditor.value = originalSource ?? ''; 342 sourceEditor.hidden = originalSource === undefined; 343 el('source-notes').hidden = originalSource !== undefined; 344 } 345 if (!preserveInputs && !automaticRun && !exportAfterLoad) 346 presetExpression(); 347 if (storedOutput() && !isTreeResult(resultCodec.value)) 348 resultCodec.value = 'tricu'; 349 sourceAvailable = data.source !== null; 350 el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; 351 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.`; 352 if (data.source !== null) 353 paintSyntax(el('source'), tokenize(data.source)); 354 if (exportAfterLoad) { 355 pauseClock(); 356 startExport(exportAfterLoad, send); 357 } 358 else if (automaticRun && savedRun) { 359 pauseClock(); 360 runClock(); 361 automaticRun = false; 362 send(savedRun); 363 } 364 else { 365 if (!preserveInputs && selected?.presets?.[exportsInput.value]) 366 setPreset(selected, exportsInput.value); 367 pauseClock(); 368 operation = undefined; 369 controls(false); 370 el('status').textContent = 'Ready'; 371 } 372 break; 373 } 374 case 'exported': { 375 pauseClock(); 376 if (data.hash !== selection?.hash || data.export !== exportsInput.value || data.format !== resultCodec.value || !storedOutput()) 377 break; 378 outputUI.file(data.file, data.export, `Stored definition ${data.export} · SHA-256 ${data.hash} · ${data.format}`); 379 revealOutput.reveal(el('result')); 380 operation = undefined; 381 controls(false); 382 el('status').textContent = 'Ready'; 383 break; 384 } 385 case 'export-error': 386 finish(data.message, true); 387 break; 388 case 'trace': { 389 el('trace-empty').hidden = true; 390 const event = data.event; 391 if (data.phase === 'before') { 392 const row = document.createElement('li'); 393 row.className = 'pending'; 394 row.textContent = `${String(event.step).padStart(2, '0')} ${event.name} ${JSON.stringify(data.event.detail)} [stack ${data.event.depth}]`; 395 traceRows.set(event.step, row); 396 el('trace').append(row); 397 } 398 else { 399 const row = traceRows.get(event.step); 400 if (row) { 401 row.textContent += `\n → ${JSON.stringify(data.event.value)}`; 402 row.classList.remove('pending'); 403 } 404 } 405 el('trace').scrollTop = el('trace').scrollHeight; 406 break; 407 } 408 case 'output': 409 el('terminal').textContent += data.text; 410 revealOutput.reveal(el('io-console')); 411 el('terminal').scrollTop = el('terminal').scrollHeight; 412 break; 413 case 'done': { 414 idleDraft(); 415 el('status').textContent = 'Finished'; 416 outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`); 417 revealOutput.reveal(el('result')); 418 el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`; 419 break; 420 } 421 case 'error': 422 if (!currentVerified) 423 loaded = false; 424 finish(data.message, true); 425 break; 426 } 427 }; 428 current.onerror = event => { if (worker === current) { 429 event.preventDefault(); 430 if (!currentVerified) 431 loaded = false; 432 finish(event.message || 'Worker failed.', true); 433 } }; 434 send({ type: 'load', ...selection }); 435 } 436 function selectExample(entry) { 437 const name = examplePackage(entry); 438 const latest = name ? packageReleases(releases, name)[0] : undefined; 439 if (!latest || (latest.demo === entry.id && latest.hash === entry.hash)) 440 select(entry, latest); 441 else 442 loadBundle(latest); 443 } 444 function select(entry, release) { 445 sourceAvailable = false; 446 draft = false; 447 scratch = false; 448 originalSource = undefined; 449 revealOutput.cancel(); 450 dispose(); 451 clearOutput(); 452 clearTreeExport(); 453 selected = entry; 454 loaded = false; 455 selection = { hash: entry.hash, localId: entry.id }; 456 hashInput.value = entry.hash; 457 sourceEditor.hidden = true; 458 el('source-notes').hidden = false; 459 const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id); 460 chooseProgram(`demo:${entry.id}`, pinned?.version); 461 showRelease(pinned); 462 setPreset(entry); 463 el('source').textContent = 'Verifying package…'; 464 newWorker(false); 465 } 466 function invalidateProgram() { 467 el('bundle-download-status').textContent = ''; 468 sourceAvailable = false; 469 draft = false; 470 scratch = false; 471 originalSource = undefined; 472 revealOutput.cancel(); 473 dispose(); 474 clearOutput(); 475 clearTreeExport(); 476 loaded = false; 477 selection = undefined; 478 selected = undefined; 479 preferredExport = undefined; 480 exportsInput.replaceChildren(); 481 sourceEditor.hidden = true; 482 el('source-notes').hidden = false; 483 el('source').textContent = 'Load the selected program to inspect its source or package notes.'; 484 el('package-info').textContent = 'No bundle loaded.'; 485 showRelease(); 486 controls(false); 487 el('status').textContent = 'Load package'; 488 } 489 function loadBundle(release) { 490 const value = release?.hash ?? hashInput.value; 491 invalidateProgram(); 492 chooseProgram(release ? releaseProgram(release) : 'hash', release?.version); 493 hashInput.value = value; 494 try { 495 const hash = bundleHash(value); 496 selection = { hash }; 497 argsInput.value = '[]'; 498 argumentsUI.refresh(true); 499 resultCodec.value = 'tricu'; 500 mode.value = 'pure'; 501 showRelease(release); 502 el('source').textContent = 'Verifying registry package…'; 503 newWorker(false); 504 } 505 catch (error) { 506 operation = 'loading'; 507 finish(error instanceof Error ? error.message : String(error), true); 508 } 509 } 510 el('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); }; 511 hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; 512 exportsInput.onchange = () => { markPreviousResult(); preferredName = exportsInput.value; clearTreeExport(); if (storedOutput()) 513 return; if (!scratch && selected?.presets?.[exportsInput.value]) 514 setPreset(selected, exportsInput.value); 515 else 516 presetExpression(); }; 517 runButton.onclick = () => { 518 if (busy || runButton.disabled) 519 return; 520 revealOutput.arm(); 521 if (storedOutput()) { 522 if (draft || !loaded || !selection) 523 return; 524 const request = { type: 'export', export: exportsInput.value, format: treeFormat(resultCodec.value) }; 525 if (worker) 526 startExport(request, value => worker?.postMessage(value)); 527 else 528 newWorker(false, true, request); 529 return; 530 } 531 if (draft) { 532 runDraft(); 533 return; 534 } 535 if (!loaded || !selection) 536 return; 537 operation = 'running'; 538 clearOutput(); 539 try { 540 const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); 541 savedRun = { type: 'run', ...expressionInput(), export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; 542 if (worker) { 543 controls(true); 544 runClock(); 545 el('status').textContent = 'Running'; 546 worker.postMessage(savedRun); 547 } 548 else 549 newWorker(true); 550 } 551 catch (error) { 552 finish(error instanceof Error ? error.message : String(error), true); 553 } 554 }; 555 function resume(value) { 556 if (!worker || !pending) 557 return; 558 const message = value === undefined ? { type: 'resume', id: pending.id } : { type: 'resume', id: pending.id, value }; 559 pending = undefined; 560 inputForm.hidden = true; 561 nextButton.disabled = true; 562 el('status').textContent = 'Running'; 563 clock(); 564 worker.postMessage(message); 565 } 566 inputForm.onsubmit = event => { 567 event.preventDefault(); 568 if (pending?.kind !== 'input') 569 return; 570 try { 571 line(new Graph({ nodes: 16_000, fuel: 100_000 }), lineInput.value); 572 el('result').textContent = ''; 573 el('result').className = ''; 574 resume(lineInput.value); 575 } 576 catch (error) { 577 el('result').textContent = error instanceof Error ? error.message : String(error); 578 el('result').className = 'error'; 579 } 580 }; 581 nextButton.onclick = () => { if (pending?.kind === 'step') 582 resume(); }; 583 stopButton.onclick = () => { 584 imports.cancel(); 585 revealOutput.cancel(); 586 finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); 587 }; 588 el('stop-run').onclick = () => stopButton.click(); 589 el('reset').onclick = () => { if (busy) 590 return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; 591 shareButton.onclick = async () => { 592 if (draft || !selection) 593 return; 594 const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value }); 595 if (selection.localId) 596 fragment.set('demo', selection.localId); 597 url.hash = fragment.toString(); 598 try { 599 await navigator.clipboard.writeText(url.href); 600 el('bundle-download-status').textContent = 'Copied a link to this package and export.'; 601 } 602 catch { 603 el('bundle-download-status').textContent = url.href; 604 } 605 }; 606 window.addEventListener('pagehide', () => dispose()); 607 // Associate an example with its published name only through the catalogue's exact pin. 608 function examplePackage(entry) { 609 return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; 610 } 611 function programPackage(value) { 612 if (value.startsWith('package:')) 613 return value.slice(8); 614 const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined; 615 return entry ? examplePackage(entry) : undefined; 616 } 617 function releaseProgram(release) { 618 const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name); 619 return entry ? `demo:${entry.id}` : `package:${release.name}`; 620 } 621 function chooseProgram(value, version) { 622 el('bundle-download-status').textContent = ''; 623 programPicker.value = value; 624 const name = programPackage(value); 625 el('program-release').hidden = name === undefined; 626 el('package-toolbar').hidden = name === undefined && value !== 'hash'; 627 el('bundle-hash-help').hidden = value !== 'hash'; 628 el('program-hash').hidden = value !== 'hash'; 629 if (name && versionPackage !== name) { 630 versionPackage = name; 631 versionPicker.replaceChildren(); 632 for (const release of packageReleases(releases, name)) { 633 const option = document.createElement('option'); 634 option.value = release.version; 635 option.textContent = release.version; 636 versionPicker.append(option); 637 } 638 } 639 if (name) 640 versionPicker.value = version ?? packageReleases(releases, name)[0].version; 641 } 642 function populatePrograms() { 643 programPicker.replaceChildren(); 644 const placeholder = document.createElement('option'); 645 placeholder.value = ''; 646 placeholder.disabled = true; 647 placeholder.textContent = 'Choose a program'; 648 programPicker.append(placeholder); 649 for (const category of exampleGroups) { 650 const group = document.createElement('optgroup'); 651 group.label = category; 652 for (const entry of entries.filter(entry => exampleGroup(entry) === category)) { 653 const option = document.createElement('option'); 654 option.value = `demo:${entry.id}`; 655 option.textContent = entry.title; 656 group.append(option); 657 } 658 if (group.children.length) 659 programPicker.append(group); 660 } 661 const packages = document.createElement('optgroup'); 662 packages.label = 'Libraries'; 663 for (const release of selectorLibraries(releases)) { 664 const option = document.createElement('option'); 665 option.value = `package:${release.name}`; 666 option.textContent = libraryLabel(release.name); 667 option.title = release.title; 668 packages.append(option); 669 } 670 const hash = document.createElement('option'); 671 hash.value = 'hash'; 672 hash.textContent = 'Load by bundle hash…'; 673 programPicker.append(packages, hash); 674 programPicker.disabled = false; 675 } 676 function showRelease(release) { 677 el('module-import-help').hidden = release === undefined; 678 el('module-import-source').textContent = release ? `!import ${release.manifest} as Library` : ''; 679 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.'; 680 } 681 programPicker.onchange = () => { 682 const value = programPicker.value; 683 if (value.startsWith('demo:')) { 684 const entry = entries.find(e => e.id === value.slice(5)); 685 if (entry) 686 selectExample(entry); 687 } 688 else { 689 chooseProgram(value); 690 if (value === 'hash') 691 hashInput.value = ''; 692 invalidateProgram(); 693 if (value.startsWith('package:')) 694 loadSelectedRelease(); 695 else if (value === 'hash') 696 hashInput.focus(); 697 } 698 }; 699 versionPicker.onchange = invalidateProgram; 700 el('load-release').onclick = loadSelectedRelease; 701 function loadSelectedRelease() { 702 const name = programPackage(programPicker.value); 703 const release = releases.find(r => r.name === name && r.version === versionPicker.value); 704 if (!release) 705 return; 706 const demo = entries.find(e => e.id === release.demo && e.hash === release.hash); 707 if (demo) 708 select(demo, release); 709 else 710 loadBundle(release); 711 } 712 function downloadHash() { 713 const name = programPackage(programPicker.value); 714 if (name) 715 return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash; 716 if (programPicker.value === 'hash') { 717 try { 718 return bundleHash(hashInput.value); 719 } 720 catch { 721 return undefined; 722 } 723 } 724 return undefined; 725 } 726 downloadButton.onclick = () => { 727 const hash = downloadHash(); 728 if (busy || draft || !hash) 729 return; 730 // Download has its own verified target, not the loaded executable selection. 731 // It never changes source, definitions, arguments or the Run/Export target. 732 dispose(); 733 operation = 'downloading'; 734 const current = new Worker(workerURL(), { type: 'module' }); 735 worker = current; 736 controls(true); 737 el('status').textContent = 'Downloading bundle'; 738 el('bundle-download-status').textContent = 'Verifying published bundle…'; 739 el('bundle-download-status').classList.remove('error'); 740 deadline.reset(PACKAGE_LOAD_MS); 741 clock(); 742 current.onmessage = ({ data }) => { 743 if (worker !== current) 744 return; 745 if (data.type === 'loaded') { 746 if (data.hash !== hash) { 747 finish('Bundle download target mismatch', true); 748 return; 749 } 750 current.postMessage({ type: 'download' }); 751 } 752 else if (data.type === 'downloaded') { 753 if (data.hash !== hash) { 754 finish('Bundle download target mismatch', true); 755 return; 756 } 757 const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); 758 const anchor = document.createElement('a'); 759 anchor.href = url; 760 anchor.download = `${hash}.arboricx`; 761 document.body.append(anchor); 762 anchor.click(); 763 anchor.remove(); 764 setTimeout(() => URL.revokeObjectURL(url), 1000); 765 const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`; 766 dispose(); 767 el('status').textContent = loaded ? 'Ready' : 'Load package'; 768 el('bundle-download-status').textContent = message; 769 } 770 else if (data.type === 'error') 771 finish(data.message, true); 772 }; 773 current.onerror = event => { if (worker === current) 774 finish(event.message || 'Worker error', true); }; 775 current.postMessage({ type: 'load', hash }); 776 }; 777 resultCodec.onchange = markPreviousResult; 778 argsInput.addEventListener('input', markPreviousResult); 779 el('argument-fields').addEventListener('input', markPreviousResult); 780 mode.onchange = () => { markPreviousResult(); el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; }; 781 expressionEditor.oninput = markPreviousResult; 782 guidedInputs.onchange = () => { 783 if (!guidedInputs.checked) { 784 try { 785 presetExpression(); 786 } 787 catch (error) { 788 guidedInputs.checked = true; 789 outputUI.clear(); 790 el('result').textContent = String(error); 791 } 792 } 793 markPreviousResult(); 794 controls(busy); 795 }; 796 sourceEditor.addEventListener('compositionstart', () => { composing = true; cancelIdleCompile(); }); 797 sourceEditor.addEventListener('compositionend', () => { composing = false; if (draft) 798 queueCompile(); }); 799 sourceEditor.oninput = () => { 800 if (busy && operation !== 'compiling') 801 return; 802 editRevision++; 803 markPreviousResult(); 804 el('compile-diagnostic').hidden = true; 805 if (operation === 'compiling') { 806 draft = true; 807 controls(true); 808 queueCompile(); 809 return; 810 } 811 preferredName = exportsInput.value || preferredName; 812 keepDraft(); 813 clearTreeExport(); 814 if (!compiled) 815 exportsInput.replaceChildren(); 816 draft = compiled || scratch || sourceEditor.value !== originalSource; 817 controls(false); 818 if (!draft && selection) { 819 preferredExport = preferredName; 820 newWorker(false, true); 821 } 822 else { 823 el('status').textContent = 'Changes pending'; 824 queueCompile(); 825 } 826 }; 827 restoreButton.onclick = () => { 828 if (originalSource === undefined) 829 return; 830 dispose(); 831 sourceEditor.value = originalSource; 832 sourceEditor.dispatchEvent(new Event('input')); 833 controls(busy); 834 }; 835 compileButton.onclick = () => compileDraft(); 836 function compileDraft() { 837 if (!draft || busy) 838 return; 839 const snapshot = { revision: editRevision, source: sourceEditor.value }; 840 dispose(); 841 operation = 'compiling'; 842 markPreviousResult(); 843 clearTreeExport(); 844 el('compile-diagnostic').hidden = true; 845 try { 846 sourceText(sourceEditor.value); 847 const current = draftWorker(snapshot); 848 controls(true); 849 deadline.reset(Infinity); 850 clock(); 851 el('status').textContent = 'Compiling'; 852 el('compiler-stats').textContent = 'Loading pinned compiler and source dependencies…'; 853 const started = performance.now(); 854 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`; }; 855 activity(); 856 compilerTimer = setInterval(activity, 1000); 857 current.postMessage({ type: 'compile', source: snapshot.source, localModules: localModules.inputs(), 858 ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); 859 } 860 catch (error) { 861 finish(error instanceof Error ? error.message : String(error), true); 862 } 863 } 864 function draftWorker(snapshot) { 865 const current = new Worker(workerURL(), { type: 'module' }); 866 worker = current; 867 const discardStale = () => { 868 if (operation !== 'compiling' || snapshot.revision === editRevision) 869 return false; 870 dispose(); 871 el('status').textContent = 'Changes pending'; 872 queueCompile(Math.max(0, idleDue - performance.now())); 873 return true; 874 }; 875 current.onmessage = ({ data }) => { 876 if (worker !== current) 877 return; 878 if ((data.type === 'compiled' || data.type === 'error') && discardStale()) 879 return; 880 switch (data.type) { 881 case 'module-exported': { 882 idleDraft(); 883 const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes).buffer], { type: 'application/octet-stream' })); 884 const anchor = document.createElement('a'); 885 anchor.href = url; 886 anchor.download = `${data.hash}.arboricx`; 887 anchor.click(); 888 URL.revokeObjectURL(url); 889 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.`; 890 el('status').textContent = 'Compiled'; 891 break; 892 } 893 case 'module-export-error': 894 idleDraft(); 895 el('compiled-module-status').textContent = data.message; 896 el('status').textContent = 'Module export failed'; 897 break; 898 case 'compiled': 899 stopCompilerActivity(); 900 pauseClock(); 901 operation = undefined; 902 compiledSource = snapshot.source; 903 compiledRevision = snapshot.revision; 904 compiled = true; 905 exportsInput.replaceChildren(); 906 for (const name of data.names) { 907 const option = document.createElement('option'); 908 option.value = name; 909 option.textContent = name; 910 exportsInput.append(option); 911 } 912 if (data.names.includes(preferredName)) 913 exportsInput.value = preferredName; 914 idleDraft(); 915 el('status').textContent = 'Compiled'; 916 compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`; 917 el('compiler-stats').textContent = compilationStats; 918 break; 919 case 'state': 920 if (data.state === 'running') { 921 clock(); 922 pending = undefined; 923 el('status').textContent = operation === 'compiling' ? 'Compiling' : operation === 'exporting' ? 'Exporting module' : 'Running'; 924 } 925 else { 926 pauseClock(); 927 pending = { id: data.id, kind: data.state }; 928 inputForm.hidden = data.state !== 'input'; 929 nextButton.disabled = data.state !== 'step'; 930 el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; 931 revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); 932 } 933 break; 934 case 'trace': { 935 const row = document.createElement('li'); 936 row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; 937 el('trace').append(row); 938 el('trace-empty').hidden = true; 939 break; 940 } 941 case 'output': 942 el('terminal').textContent += data.text; 943 revealOutput.reveal(el('io-console')); 944 break; 945 case 'done': 946 idleDraft(); 947 el('status').textContent = 'Finished'; 948 outputUI.result(data.result, `Evaluated local draft · ${backends[selectedBackend].label}`); 949 revealOutput.reveal(el('result')); 950 el('compiler-stats').textContent = `${compilationStats} · ${runtimeStats(data.stats)}`; 951 break; 952 case 'error': 953 if (operation === 'running' && !data.fatal) { 954 idleDraft(); 955 outputUI.clear(); 956 el('status').textContent = 'Stopped'; 957 el('result').textContent = operationFailure('running', data.message); 958 el('result').className = 'error'; 959 revealOutput.reveal(el('result')); 960 } 961 else 962 finish(data.message, true); 963 break; 964 } 965 }; 966 current.onerror = event => { if (worker === current) { 967 event.preventDefault(); 968 if (!discardStale()) 969 finish(event.message || 'Compiler Worker failed.', true); 970 } }; 971 return current; 972 } 973 // Definitions exist only in the owning live Worker; stopping discards them. 974 function idleDraft() { 975 stopCompilerActivity(); 976 pauseClock(); 977 operation = undefined; 978 pending = undefined; 979 inputForm.hidden = true; 980 nextButton.disabled = true; 981 controls(false); 982 } 983 function keepDraft() { 984 if (worker && compiled && !busy) 985 idleDraft(); 986 else 987 dispose(); 988 } 989 function runDraft() { 990 if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) 991 return; 992 try { 993 const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); 994 const expression = expressionInput(); 995 if (!worker || busy) 996 return; 997 operation = 'running'; 998 clearOutput(); 999 controls(true); 1000 runClock(); 1001 el('status').textContent = 'Running'; 1002 const current = worker; 1003 current.postMessage({ type: 'run', ...expression, export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); 1004 } 1005 catch (error) { 1006 outputUI.clear(); 1007 el('status').textContent = 'Stopped'; 1008 el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); 1009 el('result').className = 'error'; 1010 revealOutput.reveal(el('result')); 1011 keepDraft(); 1012 } 1013 } 1014 backendPicker.onchange = () => { 1015 selectedBackend = backend(backendPicker.value); 1016 editRevision++; 1017 dispose(); 1018 markPreviousResult(); 1019 if (draft) { 1020 controls(false); 1021 el('status').textContent = 'Changes pending'; 1022 queueCompile(0); 1023 } 1024 else if (selection) 1025 newWorker(false, true); 1026 }; 1027 const examples = { 1028 identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, 1029 contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, 1030 pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' }, 1031 definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' }, 1032 input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' }, 1033 }; 1034 for (const button of document.querySelectorAll('[data-compiler-example]')) 1035 button.onclick = () => { 1036 const math = packageReleases(releases, 'tricu-lib-native-math')[0]; 1037 const example = button.dataset['compilerExample'] === 'module' && math 1038 ? { name: 'answer', source: `!import ${math.manifest} as N\n!export answer\n\nanswer = N.add 40 2`, args: [], output: 'number', mode: 'pure' } 1039 : examples[button.dataset['compilerExample']]; 1040 if (!example) 1041 return; 1042 chooseProgram(''); 1043 revealOutput.cancel(); 1044 keepDraft(); 1045 clearOutput(); 1046 clearTreeExport(); 1047 scratch = true; 1048 draft = true; 1049 originalSource = undefined; 1050 if (!compiled) 1051 exportsInput.replaceChildren(); 1052 editRevision++; 1053 preferredName = example.name; 1054 sourceEditor.hidden = false; 1055 el('source-notes').hidden = true; 1056 sourceEditor.value = example.source; 1057 argsInput.value = JSON.stringify(example.args, null, 2); 1058 argumentsUI.refresh(true); 1059 resultCodec.value = visibleCodec(example.output); 1060 mode.value = example.mode; 1061 presetExpression(example.name); 1062 controls(false); 1063 el('package-info').textContent = 'Scratch program · no published bundle selected.'; 1064 showRelease(); 1065 el('source-label').textContent = 'Scratch program · explicit modules or contract helpers'; 1066 el('status').textContent = 'Changes pending'; 1067 queueCompile(); 1068 }; 1069 function loadFragment() { 1070 const fragment = new URLSearchParams(location.hash.slice(1)); 1071 const requested = fragment.get('bundle'); 1072 if (!requested) 1073 return false; 1074 const hash = bundleHash(requested), local = entries.find(e => e.id === fragment.get('demo') && e.hash === hash); 1075 preferredExport = fragment.get('export') ?? undefined; 1076 if (local) 1077 select(local); 1078 else { 1079 chooseProgram('hash'); 1080 showRelease(); 1081 draft = false; 1082 scratch = false; 1083 originalSource = undefined; 1084 sourceAvailable = false; 1085 revealOutput.cancel(); 1086 dispose(); 1087 clearOutput(); 1088 clearTreeExport(); 1089 loaded = false; 1090 selected = undefined; 1091 argsInput.value = '[]'; 1092 argumentsUI.refresh(true); 1093 resultCodec.value = 'tricu'; 1094 mode.value = 'pure'; 1095 selection = { hash }; 1096 hashInput.value = hash; 1097 newWorker(false); 1098 } 1099 return true; 1100 } 1101 window.addEventListener('hashchange', () => { 1102 if (!entries.length) 1103 return; 1104 try { 1105 if (new URLSearchParams(location.hash.slice(1)).has('bundle')) 1106 loadFragment(); 1107 } 1108 catch (error) { 1109 loaded = false; 1110 finish(error instanceof Error ? error.message : String(error), true); 1111 } 1112 }); 1113 let initialized = false; 1114 async function initializePackages() { 1115 if (initialized) 1116 return; 1117 initialized = true; 1118 operation = 'catalogue'; 1119 try { 1120 if (!globalThis.Worker || !globalThis.crypto?.subtle) 1121 throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).'); 1122 const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000); 1123 const input = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); 1124 if (!Array.isArray(input) || input.length > 32) 1125 throw new Error('Invalid catalogue'); 1126 entries = input.map((raw) => { 1127 if (!raw || typeof raw !== 'object') 1128 throw new Error('Invalid catalogue entry'); 1129 const v = raw; 1130 for (const key of ['id', 'title', 'source', 'sourceSha256', 'export']) 1131 if (typeof v[key] !== 'string') 1132 throw new Error('Invalid catalogue field'); 1133 const hash = bundleHash(v['hash']); 1134 literals(v['arguments']); 1135 outputType(v['output']); 1136 if (v['mode'] !== 'pure' && v['mode'] !== 'io') 1137 throw new Error('Invalid catalogue mode'); 1138 if (v['category'] !== undefined && !exampleGroups.includes(v['category'])) 1139 throw new Error('Invalid example category'); 1140 if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128)) 1141 throw new Error('Invalid demo environment'); 1142 return { ...v, hash }; 1143 }); 1144 const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576); 1145 releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes))); 1146 populatePrograms(); 1147 if (selection || draft) { 1148 operation = undefined; 1149 return; 1150 } 1151 if (!loadFragment()) { 1152 const initial = entries.find(e => e.id === 'names-values') ?? entries[0]; 1153 if (!initial) 1154 throw new Error('Empty catalogue'); 1155 selectExample(initial); 1156 } 1157 } 1158 catch (error) { 1159 initialized = false; 1160 loaded = false; 1161 finish(error instanceof Error ? error.message : String(error), true); 1162 } 1163 } 1164 setupInspector(); 1165 void initializePackages();