tricu

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

commit 5573c515459decf24799a567ec19ab1a10a5529e
parent 3d31a8ef3f64f0f216f8af4ff7c92a74086372b0
Author: James Eversole <james@eversole.co>
Date:   Sun, 18 Oct 2026 18:48:26 -0500

Refine the live editor with syntax colors and nearby evaluation results

Add lightweight native-textarea syntax highlighting, dedicated declaration colors, and caret bracket matching. Preserve native undo, IME, accessibility, and mobile layout.

Place Evaluate and Stop beside the expression and keep output directly below. Reveal offscreen mobile output once without stealing focus or following subsequent IO events.

Retire JavaScript as a production backend, retain its evaluator and checkpoint drivers for conformance tests, and exclude those drivers from release assets. Keep Haskell as default with Zig as the alternative.

Isolate Firefox test state, extend layout and runtime regression coverage, and label triangle source with the symbol alone.

Diffstat:
Mext/demo-site/README.md | 102++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mext/demo-site/dist/app.js | 108+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Dext/demo-site/dist/compiler-worker.js | 134-------------------------------------------------------------------------------
Aext/demo-site/dist/conformance/compiler-protocol.js | 1+
Aext/demo-site/dist/conformance/compiler-worker.js | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/conformance/expression.js | 24++++++++++++++++++++++++
Aext/demo-site/dist/conformance/worker.js | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/expression.js | 24+-----------------------
Aext/demo-site/dist/output-reveal.js | 37+++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/runtime-backends.js | 5++---
Aext/demo-site/dist/syntax.js | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dext/demo-site/dist/worker.js | 115-------------------------------------------------------------------------------
Mext/demo-site/index.html | 39++++++++++++++++++++-------------------
Mext/demo-site/release.nix | 2++
Mext/demo-site/src/app.ts | 100+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mext/demo-site/src/compiler-protocol.ts | 5+----
Dext/demo-site/src/compiler-worker.ts | 105-------------------------------------------------------------------------------
Aext/demo-site/src/conformance/compiler-protocol.ts | 12++++++++++++
Aext/demo-site/src/conformance/compiler-worker.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/src/conformance/expression.ts | 21+++++++++++++++++++++
Aext/demo-site/src/conformance/worker.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/expression.ts | 21+--------------------
Aext/demo-site/src/output-reveal.ts | 32++++++++++++++++++++++++++++++++
Mext/demo-site/src/runtime-backends.ts | 7+++----
Aext/demo-site/src/syntax.ts | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dext/demo-site/src/worker.ts | 88-------------------------------------------------------------------------------
Mext/demo-site/style.css | 20++++++++++++++++++--
Mext/demo-site/test/backend-browser.mjs | 11+++++++++++
Mext/demo-site/test/backend-firefox.mjs | 9+++++++--
Mext/demo-site/test/backend-probe.mjs | 4++--
Mext/demo-site/test/backend-server.mjs | 2++
Mext/demo-site/test/compiler-package.test.mjs | 2+-
Aext/demo-site/test/conformance-worker.test.mjs | 34++++++++++++++++++++++++++++++++++
Aext/demo-site/test/evaluation-layout-probe.mjs | 43+++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/test/ide-probe.mjs | 5+++++
Mext/demo-site/test/live-ux-probe.mjs | 2+-
Aext/demo-site/test/output-reveal.test.mjs | 35+++++++++++++++++++++++++++++++++++
Mext/demo-site/test/release.py | 9++++++---
Mext/demo-site/test/runtime-backends.test.mjs | 20++++++++++++--------
Mext/demo-site/test/site.test.mjs | 16++++++++++++++--
Aext/demo-site/test/syntax-probe.mjs | 33+++++++++++++++++++++++++++++++++
Aext/demo-site/test/syntax.test.mjs | 41+++++++++++++++++++++++++++++++++++++++++
Mext/haskell-wasm/browser.nix | 2++
Mext/wasm/README.md | 2+-
44 files changed, 1386 insertions(+), 670 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -10,22 +10,69 @@ present; loading verifies a package but never executes it or downloads a compile kit. Haskell is the initial runtime. There are no tabs or separate export page. The editor combines source, an attached expression prompt, and a single output -surface. The sticky toolbar keeps Evaluate/Show definition, Stop and Settings -available. Settings dock on desktop and become a dismissible modal sheet on +surface in one column: source → expression → Evaluate/Stop → result. Output +interpretation, target and execution controls sit above the expression; choosing +a stored definition hides the unused expression inputs. The sticky toolbar keeps +Program, Runtime, Settings and status, plus Stop while work is active. Results +remain directly beneath the action even with the desktop inspector open. Settings dock on desktop and become a dismissible modal sheet on mobile, with Escape, focus containment/return, and inert background controls. The mobile source editor reaches both viewport edges; padding is inside the editor, not an outer margin. Interpretation and execution mode remain visible outside settings. The footer credits Copyright 2026 James Eversole. +On mobile, an explicit Evaluate/Show definition action can reveal the beginning +of offscreen output once, below the sticky toolbar. Already-visible results stay +put; large output is not scrolled to its end. The first IO output/input/step may +use this reveal instead, but later IO events and completion never repeat it. +There is no automatic focus transfer or keyboard opening. Desktop evaluations, +idle compilation and package arrival do not auto-scroll. Stop, source/settings +changes, page departure, or deliberate wheel/touch/scroll-key browsing cancel a +pending reveal; an open mobile settings sheet suppresses it. Movement is instant, +including with reduced-motion enabled. + +## Syntax colors and bracket matching + +Source, read-only tricu source, and the expression prompt distinguish comments, +strings/escapes, numbers, the kernel `t`, current keywords, module directives, +operators and delimiters. Definition **declaration sites** have one dedicated +color, including ordinary local equations; a simple contract name after `@` or +`=@` has an annotation color. References are not semantically resolved or colored +by binding identity. Declaration recognition is a conservative lexical/layout +heuristic, not a second compiler; compiler diagnostics remain authoritative. + +A collapsed caret immediately beside `()`, `[]` or `{}` highlights its matching +partner. An unmatched delimiter gets a wavy underline. Strings and comments are +excluded, including non-nesting block comments. Range selections do not highlight +a pair. There is no automatic bracket insertion, indentation rewriting or +completion. Editing and expression evaluation keep their existing behavior. + +The small local `syntax.ts` renderer decorates native textareas rather than +replacing them with contenteditable or an external editor framework. Decorative +mirrors are hidden from accessibility APIs and never interpret source as HTML. +Selection, undo, resizing and scrolling remain native. During IME composition and +in forced-colors mode, native text remains visible instead of the colored mirror. +Programmatic source/preset changes explicitly refresh the decoration without +synthesizing edits or starting compilation. Sources over 32,768 UTF-16 code units +fall back to plain text, bounding decoration work without truncating source or +changing execution limits. This also covers large read-only library sources. + ## Runtime selection -The **Runtime** selector offers **Haskell** (default), **Zig**, and -**JavaScript**. Haskell is the reference evaluator; the Haskell and Zig browser +The **Runtime** selector offers **Haskell** (default) and **Zig**. Haskell is the reference evaluator; the Haskell and Zig browser backends run as WASM. Selection is explicit and page-local; failures never silently switch -backends. Changing runtime discards the old runtime/checkpoint, preserves source +backends. Changing runtime discards the old runtime, preserves source text, and automatically recompiles a draft after switching. Published selections reload on the chosen runtime. Switching is disabled while work is active; Stop first. +The JavaScript evaluator is retired as a user-selectable backend: its compilation +latency is unsuitable for the live-compilation workflow. Its independent graph, +codec and compiler conformance coverage remains. Test-only JS Workers and their +checkpoint protocol live under `src/conformance/` (generated into +`dist/conformance/`) and are excluded from the production release. The old +`dist/worker.js` and `dist/compiler-worker.js` entrypoints are removed, not aliases. +Shared JavaScript authentication, literal codecs and tree formatting remain in +use; there is no JS execution fallback. + The release packages the pinned Haskell reactor, loader, WASI shim and authenticated compiler kit under `runtime/haskell/`. Loading and structured-argument runs do not download the compiler kit. Expression evaluation loads it on demand, retaining the loaded definitions. Draft compilation checks the runtime's original compiler-bundle @@ -36,7 +83,7 @@ It has no WASI imports. Its compiler session fetches the exact authenticated compiler bundle selected by `compiler.json`, rather than loading Haskell's prepared kit. Both use the same environment/profile pins and names. -All runtimes use shared bundle authentication, literal validation, immutable +Both runtimes use shared bundle authentication, literal validation, immutable stored-definition export and the same page IO protocol. `runtime-session.ts` defines the coarse adapter boundary: load, compile, run/runExpression with async IO callbacks, diagnostics and disposal. Private pointers, IO tokens and heap checkpoints do not @@ -267,9 +314,8 @@ compilation diagnostics appear beside the source without replacing that output. The editor/authorship modified color clears only when the current revision is compiled or the published source is restored. -Ordinary run errors retain usable compiled definitions. JS restores its private -checkpoint for each attempt; WASM retains definitions in its live Worker. -Evaluation is disabled while either is busy, including while waiting for IO. +Ordinary run errors retain usable compiled definitions in the live Worker. +Evaluation is disabled while the session is busy, including while waiting for IO. Stop must precede replacement of an active attempt. Hard Stop or a fatal WASM failure loses that session's definitions, but preserves source. **Compile now** or another edit rebuilds them; Stop does not automatically restart work. @@ -341,7 +387,7 @@ explicit, not inferred. Former tree/list graph-JSON presets now select tricu tre source. The internal bounded graph previews remain available to conformance harnesses, not as a public editor output. -Tree interpretations are **tricu · t source**, **Lambada · △ source**, **DAG · +Tree interpretations are **tricu · t source**, **△ source**, **DAG · shared △**, and **Ternary**. Evaluation formats the actual applied result (or completed IO value), not its function definition. A complete, physical-sharing snapshot is bounded to 100,000 observed nodes; incomplete or cyclic snapshots are @@ -468,23 +514,7 @@ to 512 scalars, and displayed definitions to 16 names of 128 scalars each. Input is permitted. Compilation has no application node, work, frame or elapsed-time ceiling, but still has engine/OS limits. Stop terminates the Worker. -For JavaScript, one compilation supports repeated applications through a page-local checkpoint -of the collected lazy graph and definition handles. Checkpointing neither forces -code nor expands shared trees. Every run uses a new Worker and its own graph copy, -so an error, partial reduction, IO pause or hard Worker termination cannot damage -the saved definitions. Compilation and successful-run work are -reported cumulatively; failed/terminated work is not included in that total. - -The checkpoint remains in memory only, adding a graph-sized retained copy plus -per-run structured-clone overhead. There is no browser storage or source -recompilation on retry. Completed Workers are terminated. IO retries execute -fresh actions and request fresh input; already performed external effects are -not rolled back. Failed compilation or a different loaded program invalidates -the checkpoint, and page reload discards it. Selection links never fetch the -compiler or automatically execute source. Ordinary examples still need no -compiler download. - -Each WASM backend instead retains its lazy definition set inside its live runtime Worker. +Each backend retains its lazy definition set inside its live runtime Worker. It does not export a JS checkpoint or eagerly normalize programs to simulate one. Successful runs and recoverable result-decoding/IO errors keep that set for reuse; IO continuations/results are cancelled after each finished attempt. Fatal errors @@ -703,7 +733,10 @@ validation evidence, and complete scratch retention. The site/dependency outputs must match; do not activate a site without its corresponding registry dependencies. These steps prepare and validate local release assets, not a public deployment. -## Runtime architecture (JavaScript backend) +## Shared browser code and JavaScript conformance evaluator + +The JavaScript graph implementation below is retained for independent tests and +shared literal/format helpers, not as a production program-execution backend. - `src/graph.ts`: Worker-local heap of immutable constructors, lazy application thunks, and memoized forwarding references. Explicit continuation frames force @@ -723,14 +756,13 @@ These steps prepare and validate local release assets, not a public deployment. `tree-result.ts` and the shared `tree-export.ts` formatter; it is never a heap checkpoint. List view limits and natural/string validation fail explicitly. - `src/io.ts`: action/continuation handles remain in that same heap throughout IO. Trace summaries do not traverse arbitrary pure payloads or continuations. -- `src/protocol.ts`, `worker.ts`, `app.ts`: typed discriminated Worker messages, - branded node/hash/request identities, one load, and one run per ordinary Worker. Main - page owns watchdog/termination; inputs use request IDs. `compiler.ts` and - `compiler-{protocol,worker}.ts` add the compiler session - and its retained definition handles. All package strings, +- `src/protocol.ts` and `compiler-protocol.ts` define production Worker messages. + `app.ts` owns termination; native sessions retain definitions privately. + `src/conformance/{worker,compiler-worker,compiler-protocol}.ts` retain JS-only + evaluator/checkpoint drivers for tests, excluded from the shipped site. All package strings, source, diagnostics, and results are rendered as text, never HTML. -One run does not mutate shared constructor data. Only its own application thunks +In the test-only JS drivers, one run does not mutate shared constructor data. Only its own application thunks are memoized. No cross-run cache or runtime constructor hash-consing is implemented; completed package runs and discarded/replaced compiler sessions discard the entire Worker. Successful compiler runs retain definition roots for reuse. @@ -1004,6 +1036,6 @@ security and runtime conformance controls remain. SITE=$(nix build .#arboricxSite --no-link --print-out-paths) TEST_IDE=1 ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium -# TEST_BACKEND=zig-wasm or js selects another runtime. +# TEST_BACKEND=zig-wasm selects the other production runtime. # Use backend-firefox.mjs and nixpkgs#firefox for Firefox. ``` diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -1,5 +1,7 @@ +import { outputReveal } from './output-reveal.js'; +import { syntaxEditor, tokenize, paintSyntax } from './syntax.js'; import { printLiteral } from './literal-input.js'; -import { backend, backends, graphStats, runtimeStats } from './runtime-backends.js'; +import { backend, backends, runtimeStats } from './runtime-backends.js'; import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; import { createOutput } from './output-app.js'; @@ -23,6 +25,8 @@ const sourceEditor = el('source-editor'), compileButton = el('compile'); const restoreButton = el('restore-source'), downloadButton = el('download-bundle'); let sourceAvailable = false; const expressionEditor = el('expression-editor'); +const refreshSourceSyntax = syntaxEditor(sourceEditor), refreshExpressionSyntax = syntaxEditor(expressionEditor); +const revealOutput = outputReveal(document.querySelector('.editor-toolbar')); const guidedInputs = el('use-guided-inputs'); let editRevision = 0, composing = false, idleDue = 0; let idleTimer; @@ -39,22 +43,22 @@ function queueCompile(delay = 650) { }, delay); } function markPreviousResult() { + revealOutput.cancel(); if (el('result').textContent || el('terminal').textContent) el('result-stale').hidden = false; } function presetExpression(name = exportsInput.value || preferredName) { expressionEditor.value = [name, ...argumentsUI.read().map(printLiteral)].join(' '); + refreshExpressionSyntax(); } function expressionInput() { return guidedInputs.checked ? {} : { expression: sourceText(expressionEditor.value) }; } const backendPicker = el('runtime-backend'); let selectedBackend = backend(backendPicker.value); -const persistentDefinitions = () => backends[selectedBackend].persistentDefinitions; -const workerURL = (compiler = false) => new URL(compiler ? backends[selectedBackend].compiler : backends[selectedBackend].worker, import.meta.url); +const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url); let draft = false, scratch = false, compiled = false, busy = false, originalSource; -let compiledDraft, compiledSource, compiledRevision; -let totalWork = 0; +let compiledSource, compiledRevision; let compilationStats = '', preferredName = 'id'; let compilerTimer; function stopCompilerActivity() { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } @@ -87,6 +91,7 @@ function syncOutputTarget() { outputTarget.onchange = () => { if (busy) return; + revealOutput.cancel(); if (storedOutput()) resultInterpretation = resultCodec.value; else @@ -149,13 +154,15 @@ function controls(working) { for (const button of document.querySelectorAll('[data-compiler-example]')) button.disabled = busy; stopButton.disabled = !busy && !draft; + stopButton.hidden = !busy; + el('stop-run').disabled = stopButton.disabled; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); expressionEditor.disabled = busy && operation !== 'compiling'; guidedInputs.disabled = busy; - el('expression-inputs').hidden = guidedInputs.checked; - el('guided-inputs').hidden = !guidedInputs.checked; + el('expression-inputs').hidden = storedOutput() || guidedInputs.checked; + el('guided-inputs').hidden = storedOutput() || !guidedInputs.checked; mode.disabled = busy || storedOutput(); outputTarget.disabled = busy; resultCodec.disabled = busy; @@ -163,20 +170,19 @@ function controls(working) { shareButton.disabled = busy || draft || !loaded; el('reset').disabled = busy; outputUI.controls(busy); + refreshSourceSyntax(); + refreshExpressionSyntax(); } -function dispose(preserveDraft = false) { +function dispose() { cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; - if (!preserveDraft) { - compiledDraft = undefined; - compiledSource = undefined; - compiledRevision = undefined; - } - compiled = compiledDraft !== undefined; + compiledSource = undefined; + compiledRevision = undefined; + compiled = false; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; @@ -214,11 +220,12 @@ function finish(message, error = false, reason = 'failure') { el('bundle-download-status').classList.toggle('error', error); } const downloadOnly = operation === 'downloading'; - dispose(draft && (operation !== 'compiling' || reason === 'cancelled') && operation !== 'loading'); + dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; if (!downloadOnly) { el('result').textContent = message; el('result').className = error ? 'error' : ''; + revealOutput.reveal(el('result')); } for (const row of traceRows.values()) row.classList.remove('pending'); @@ -272,8 +279,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; - if (data.state === 'input') - lineInput.focus(); + revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); } break; case 'loaded': { @@ -305,6 +311,8 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { sourceAvailable = data.source !== null; el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; 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.`; + if (data.source !== null) + paintSyntax(el('source'), tokenize(data.source)); if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); @@ -330,6 +338,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { if (data.hash !== selection?.hash || data.export !== exportsInput.value || data.format !== resultCodec.value || !storedOutput()) break; outputUI.file(data.file, data.export, `Stored definition ${data.export} · SHA-256 ${data.hash} · ${data.format}`); + revealOutput.reveal(el('result')); operation = undefined; controls(false); el('status').textContent = 'Ready'; @@ -360,15 +369,14 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { } case 'output': el('terminal').textContent += data.text; + revealOutput.reveal(el('io-console')); el('terminal').scrollTop = el('terminal').scrollHeight; break; case 'done': { - if (persistentDefinitions()) - idleDraft(); - else - dispose(); + idleDraft(); el('status').textContent = 'Finished'; outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`); + revealOutput.reveal(el('result')); el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`; break; } @@ -400,6 +408,7 @@ function select(entry, release) { draft = false; scratch = false; originalSource = undefined; + revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); @@ -422,6 +431,7 @@ function invalidateProgram() { draft = false; scratch = false; originalSource = undefined; + revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); @@ -467,8 +477,9 @@ exportsInput.onchange = () => { markPreviousResult(); preferredName = exportsInp else presetExpression(); }; runButton.onclick = () => { - if (busy) + if (busy || runButton.disabled) return; + revealOutput.arm(); if (storedOutput()) { if (draft || !loaded || !selection) return; @@ -531,9 +542,10 @@ inputForm.onsubmit = event => { }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish(draft && compiledDraft ? 'Stopped. Last compiled draft retained.' : draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); +stopButton.onclick = () => { revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); }; +el('stop-run').onclick = () => stopButton.click(); el('reset').onclick = () => { if (busy) - return; clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; + return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; @@ -774,7 +786,7 @@ function compileDraft() { if (!draft || busy) return; const snapshot = { revision: editRevision, source: sourceEditor.value }; - dispose(true); + dispose(); operation = 'compiling'; markPreviousResult(); clearTreeExport(); @@ -799,10 +811,10 @@ function compileDraft() { } } function draftWorker(snapshot) { - const current = new Worker(workerURL(true), { type: 'module' }); + const current = new Worker(workerURL(), { type: 'module' }); worker = current; const discardStale = () => { - if (operation !== 'compiling' || snapshot?.revision === editRevision) + if (operation !== 'compiling' || snapshot.revision === editRevision) return false; dispose(); el('status').textContent = 'Changes pending'; @@ -819,11 +831,9 @@ function draftWorker(snapshot) { stopCompilerActivity(); pauseClock(); operation = undefined; - compiledDraft = data.draft; compiledSource = snapshot.source; compiledRevision = snapshot.revision; compiled = true; - totalWork = graphStats(data.stats) ? data.stats.work : 0; exportsInput.replaceChildren(); for (const name of data.names) { const option = document.createElement('option'); @@ -833,12 +843,9 @@ function draftWorker(snapshot) { } if (data.names.includes(preferredName)) exportsInput.value = preferredName; - if (persistentDefinitions()) - idleDraft(); - else - dispose(true); + idleDraft(); el('status').textContent = 'Compiled'; - compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${graphStats(data.stats) ? `${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${data.stats.work.toLocaleString()} work units` : runtimeStats(data.stats)}`; + compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`; el('compiler-stats').textContent = compilationStats; break; case 'state': @@ -853,6 +860,7 @@ function draftWorker(snapshot) { 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); } break; case 'trace': { @@ -864,25 +872,23 @@ function draftWorker(snapshot) { } case 'output': el('terminal').textContent += data.text; + revealOutput.reveal(el('io-console')); break; case 'done': - if (graphStats(data.stats)) - totalWork += data.stats.work; - if (persistentDefinitions()) - idleDraft(); - else - dispose(true); + idleDraft(); el('status').textContent = 'Finished'; outputUI.result(data.result, `Evaluated local draft · ${backends[selectedBackend].label}`); - el('compiler-stats').textContent = `${compilationStats} · ${graphStats(data.stats) ? `${totalWork.toLocaleString()} cumulative work` : runtimeStats(data.stats)}`; + revealOutput.reveal(el('result')); + el('compiler-stats').textContent = `${compilationStats} · ${runtimeStats(data.stats)}`; break; case 'error': - if (persistentDefinitions() && operation === 'running' && !data.fatal) { + if (operation === 'running' && !data.fatal) { idleDraft(); outputUI.clear(); el('status').textContent = 'Stopped'; el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; + revealOutput.reveal(el('result')); } else finish(data.message, true); @@ -896,8 +902,7 @@ function draftWorker(snapshot) { } }; return current; } -// Persistent backends retain definitions only in their live Worker. JS may -// restore its backend-private checkpoint; no checkpoint crosses backend types. +// Definitions exist only in the owning live Worker; stopping discards them. function idleDraft() { stopCompilerActivity(); pauseClock(); @@ -908,10 +913,10 @@ function idleDraft() { controls(false); } function keepDraft() { - if (persistentDefinitions() && worker && compiled && !busy) + if (worker && compiled && !busy) idleDraft(); else - dispose(true); + dispose(); } function runDraft() { if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) @@ -919,18 +924,14 @@ function runDraft() { try { const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); const expression = expressionInput(); - if (!persistentDefinitions()) - dispose(true); - else if (!worker || busy) + if (!worker || busy) return; operation = 'running'; clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; - const current = persistentDefinitions() ? worker : draftWorker(); - if (!persistentDefinitions()) - current.postMessage({ type: 'restore', draft: compiledDraft }); + const current = worker; current.postMessage({ type: 'run', ...expression, export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); } catch (error) { @@ -938,6 +939,7 @@ function runDraft() { el('status').textContent = 'Stopped'; el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; + revealOutput.reveal(el('result')); keepDraft(); } } @@ -967,6 +969,7 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) if (!example) return; chooseProgram(''); + revealOutput.cancel(); keepDraft(); clearOutput(); clearTreeExport(); @@ -1008,6 +1011,7 @@ function loadFragment() { scratch = false; originalSource = undefined; sourceAvailable = false; + revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); diff --git a/ext/demo-site/dist/compiler-worker.js b/ext/demo-site/dist/compiler-worker.js @@ -1,134 +0,0 @@ -import { compileExpression } from './expression.js'; -import { resumeValue } from './io.js'; -import { environmentProfile } from './packages.js'; -import { BROWSER_BUDGET, collectionPolicy } from './execution.js'; -import { contractEnvironment } from './contracts.js'; -import { Graph } from './graph.js'; -import { COMPILER_BUNDLE_LIMIT, fetchBundle, fetchBytes, loadBundle } from './bundle.js'; -import { literals, makeLiteral, outputType, view, list, text } from './codecs.js'; -import { runIO } from './io.js'; -import { COMPILER_EXPORT, compilerDescriptor, sourceLiteral, sourceText, compiledDefinitions } from './compiler.js'; -const scope = globalThis; -const base = new URL('../', import.meta.url); -const send = (message) => scope.postMessage(message); -const running = () => send({ type: 'state', state: 'running' }); -let phase = 'empty'; -let graph, exports; -let requestId = 0; -let pending; -function request(kind) { - if (pending) - throw new Error('Driver already waiting'); - const id = ++requestId; - return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); -} -async function compile(source, profileName) { - phase = 'compiling'; - running(); - const start = performance.now(); - const budget = BROWSER_BUDGET; - graph = new Graph(budget, collectionPolicy(budget)); - const input = sourceLiteral(graph, source); // Validate before downloading a compiler. - const bytes = await fetchBytes(new URL('compiler.json', base), 4096); - const raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); - const { hash } = compilerDescriptor(raw); - const bundle = await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT); - const library = loadBundle(graph, bundle), compiler = library.get(COMPILER_EXPORT); - if (compiler === undefined) - throw new Error('Missing stable compiler entrypoint'); - let environment; - if (profileName === undefined) - environment = contractEnvironment(graph, library); - else { - const bytes = await fetchBytes(new URL('environments.json', base), 32_768); - const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), profileName); - if (selected.compiler !== hash) - throw new Error('Demo environment/compiler pin mismatch'); - const bindings = []; - if (selected.profile) { - const support = await fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, base), selected.profile.hash); - const values = loadBundle(graph, support); - for (const name of selected.profile.names) { - const tree = values.get(name); - if (tree === undefined) - throw new Error(`Missing demo environment binding ${name}`); - bindings.push(graph.fork(text(graph, name), tree)); - } - } - environment = list(graph, bindings); - } - exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input])); - graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots. - phase = 'ready'; - send({ type: 'compiled', names: [...exports.keys()], draft: { graph: graph.checkpoint(), exports: [...exports] }, stats: graph.stats, milliseconds: performance.now() - start }); -} -async function run(message) { - if (!graph || !exports) - throw new Error('Compile a unit first'); - phase = 'running'; - running(); - const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); - if (fn === undefined) - throw new Error('Unknown compiled definition'); - if (message.mode !== 'pure' && message.mode !== 'io') - throw new Error('Unsupported execution mode'); - const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph, value)); - let result = message.expression === undefined ? graph.apply(fn, args) : fn; - if (message.mode === 'io') - result = await runIO(graph, result, { - async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step) - await request('step'); running(); }, - after(event) { send({ type: 'trace', phase: 'after', event }); }, write(text) { send({ type: 'output', text }); }, - read() { return request('input'); }, running, - }, true, exports.values()); - const displayed = view(graph, result, type); - graph.collect(exports.values()); // Drop this run's temporaries, retaining every definition. - phase = 'ready'; - send({ type: 'done', result: displayed, stats: graph.stats }); -} -scope.onmessage = ({ data }) => { - if (!data || typeof data !== 'object' || Array.isArray(data)) - return; - const message = data; - if (message['type'] === 'resume') { - if (!pending || message['id'] !== pending.id) - return; - const value = resumeValue(pending.kind, message['value']); - if (value === undefined) - return; - const waiter = pending; - pending = undefined; - waiter.resolve(value); - return; - } - let operation; - try { - if (message['type'] === 'restore' && phase === 'empty') { - // Only the owning page supplies this trusted in-memory checkpoint. - const restored = data; - const budget = BROWSER_BUDGET; - graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); - exports = new Map(restored.draft.exports); - phase = 'ready'; - return; - } - else if (message['type'] === 'compile' && phase === 'empty') { - if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) - throw new Error('Invalid demo environment'); - operation = compile(message['source'], message['environment']); - } - else if (message['type'] === 'run' && phase === 'ready') { - if (typeof message['export'] !== 'string' || message['export'].length > 128) - throw new Error('Invalid compiled selection'); - operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], - ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), - output: outputType(message['output']), mode: message['mode'], step: message['step'] === true }); - } - else - return; - } - catch (error) { - operation = Promise.reject(error); - } - operation.catch((error) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); -}; diff --git a/ext/demo-site/dist/conformance/compiler-protocol.js b/ext/demo-site/dist/conformance/compiler-protocol.js @@ -0,0 +1 @@ +export {}; diff --git a/ext/demo-site/dist/conformance/compiler-worker.js b/ext/demo-site/dist/conformance/compiler-worker.js @@ -0,0 +1,135 @@ +// Test-only JS evaluator driver; excluded from the production site. +import { compileExpression } from './expression.js'; +import { resumeValue } from '../io.js'; +import { environmentProfile } from '../packages.js'; +import { BROWSER_BUDGET, collectionPolicy } from '../execution.js'; +import { contractEnvironment } from '../contracts.js'; +import { Graph } from '../graph.js'; +import { COMPILER_BUNDLE_LIMIT, fetchBundle, fetchBytes, loadBundle } from '../bundle.js'; +import { literals, makeLiteral, outputType, view, list, text } from '../codecs.js'; +import { runIO } from '../io.js'; +import { COMPILER_EXPORT, compilerDescriptor, sourceLiteral, sourceText, compiledDefinitions } from '../compiler.js'; +const scope = globalThis; +const base = new URL('../../', import.meta.url); +const send = (message) => scope.postMessage(message); +const running = () => send({ type: 'state', state: 'running' }); +let phase = 'empty'; +let graph, exports; +let requestId = 0; +let pending; +function request(kind) { + if (pending) + throw new Error('Driver already waiting'); + const id = ++requestId; + return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); +} +async function compile(source, profileName) { + phase = 'compiling'; + running(); + const start = performance.now(); + const budget = BROWSER_BUDGET; + graph = new Graph(budget, collectionPolicy(budget)); + const input = sourceLiteral(graph, source); // Validate before downloading a compiler. + const bytes = await fetchBytes(new URL('compiler.json', base), 4096); + const raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + const { hash } = compilerDescriptor(raw); + const bundle = await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT); + const library = loadBundle(graph, bundle), compiler = library.get(COMPILER_EXPORT); + if (compiler === undefined) + throw new Error('Missing stable compiler entrypoint'); + let environment; + if (profileName === undefined) + environment = contractEnvironment(graph, library); + else { + const bytes = await fetchBytes(new URL('environments.json', base), 32_768); + const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), profileName); + if (selected.compiler !== hash) + throw new Error('Demo environment/compiler pin mismatch'); + const bindings = []; + if (selected.profile) { + const support = await fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, base), selected.profile.hash); + const values = loadBundle(graph, support); + for (const name of selected.profile.names) { + const tree = values.get(name); + if (tree === undefined) + throw new Error(`Missing demo environment binding ${name}`); + bindings.push(graph.fork(text(graph, name), tree)); + } + } + environment = list(graph, bindings); + } + exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input])); + graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots. + phase = 'ready'; + send({ type: 'compiled', names: [...exports.keys()], draft: { graph: graph.checkpoint(), exports: [...exports] }, stats: graph.stats, milliseconds: performance.now() - start }); +} +async function run(message) { + if (!graph || !exports) + throw new Error('Compile a unit first'); + phase = 'running'; + running(); + const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); + if (fn === undefined) + throw new Error('Unknown compiled definition'); + if (message.mode !== 'pure' && message.mode !== 'io') + throw new Error('Unsupported execution mode'); + const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph, value)); + let result = message.expression === undefined ? graph.apply(fn, args) : fn; + if (message.mode === 'io') + result = await runIO(graph, result, { + async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step) + await request('step'); running(); }, + after(event) { send({ type: 'trace', phase: 'after', event }); }, write(text) { send({ type: 'output', text }); }, + read() { return request('input'); }, running, + }, true, exports.values()); + const displayed = view(graph, result, type); + graph.collect(exports.values()); // Drop this run's temporaries, retaining every definition. + phase = 'ready'; + send({ type: 'done', result: displayed, stats: graph.stats }); +} +scope.onmessage = ({ data }) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) + return; + const message = data; + if (message['type'] === 'resume') { + if (!pending || message['id'] !== pending.id) + return; + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) + return; + const waiter = pending; + pending = undefined; + waiter.resolve(value); + return; + } + let operation; + try { + if (message['type'] === 'restore' && phase === 'empty') { + // Only the owning conformance harness supplies this trusted in-memory checkpoint. + const restored = data; + const budget = BROWSER_BUDGET; + graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); + exports = new Map(restored.draft.exports); + phase = 'ready'; + return; + } + else if (message['type'] === 'compile' && phase === 'empty') { + if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) + throw new Error('Invalid demo environment'); + operation = compile(message['source'], message['environment']); + } + else if (message['type'] === 'run' && phase === 'ready') { + if (typeof message['export'] !== 'string' || message['export'].length > 128) + throw new Error('Invalid compiled selection'); + operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], + ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), + output: outputType(message['output']), mode: message['mode'], step: message['step'] === true }); + } + else + return; + } + catch (error) { + operation = Promise.reject(error); + } + operation.catch((error) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); +}; diff --git a/ext/demo-site/dist/conformance/expression.js b/ext/demo-site/dist/conformance/expression.js @@ -0,0 +1,24 @@ +import { sourceLiteral, compiledDefinitions, compilerDescriptor, COMPILER_EXPORT } from '../compiler.js'; +import { fetchBytes, fetchBundle, loadBundle, COMPILER_BUNDLE_LIMIT } from '../bundle.js'; +import { list, text } from '../codecs.js'; +import { expressionUnit, expressionDiagnostic } from '../expression.js'; +export async function compileExpression(graph, definitions, input) { + const unit = expressionUnit(input, definitions.keys()); + const base = new URL('../../', import.meta.url); + const descriptor = await fetchBytes(new URL('compiler.json', base), 4096); + const { hash } = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(descriptor))); + const library = loadBundle(graph, await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT)); + const compiler = library.get(COMPILER_EXPORT); + if (compiler === undefined) + throw new Error('Missing stable compiler entrypoint'); + const environment = list(graph, [...definitions].map(([name, tree]) => graph.fork(text(graph, name), tree))); + try { + const values = compiledDefinitions(graph, graph.apply(compiler, [environment, sourceLiteral(graph, unit.source)])); + if (values.size !== 1 || !values.has(unit.name)) + throw new Error('Enter one expression, not a source unit'); + return values.get(unit.name); + } + catch (error) { + throw expressionDiagnostic(error); + } +} diff --git a/ext/demo-site/dist/conformance/worker.js b/ext/demo-site/dist/conformance/worker.js @@ -0,0 +1,116 @@ +// Test-only JS evaluator driver; excluded from the production site. +import { sourceText } from '../compiler.js'; +import { compileExpression } from './expression.js'; +import { resumeValue } from '../io.js'; +import { publishedBundle } from '../published-bundle.js'; +import { exportTree, treeFormat } from '../tree-export.js'; +import { BROWSER_BUDGET, collectionPolicy, PACKAGE_COLLECTION } from '../execution.js'; +import { Graph } from '../graph.js'; +import { bundleHash, loadBundle } from '../bundle.js'; +import { literals, makeLiteral, outputType, view } from '../codecs.js'; +import { runIO } from '../io.js'; +const scope = globalThis; +const send = (message) => scope.postMessage(message); +const running = () => send({ type: 'state', state: 'running' }); +let phase = 'empty'; +let graph, exports; +let stored; +let requestId = 0; +let pending; +function request(kind) { + if (pending) + throw new Error('Driver already waiting'); + const id = ++requestId; + return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); +} +async function load(hash, localId) { + phase = 'loading'; + running(); + const loaded = await publishedBundle(hash, localId); + const { bundle, source } = loaded; + stored = loaded; + const budget = BROWSER_BUDGET; + graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); + exports = loadBundle(graph, bundle); + phase = 'ready'; + send({ type: 'loaded', hash, exports: bundle.exports, nodes: bundle.nodes.length, metadata: bundle.metadata, source }); +} +async function run(message) { + if (phase !== 'ready' || !graph || !exports) + throw new Error('Load a bundle before running'); + phase = 'running'; + running(); + const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); + if (fn === undefined) + throw new Error('Unknown export'); + if (message.mode !== 'pure' && message.mode !== 'io') + throw new Error('Unsupported execution mode'); + const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph, value)); + let result = message.expression === undefined ? graph.apply(fn, args) : fn; + graph.collect([result]); // Exclusive one-run session: only this application remains owned. + if (message.mode === 'io') + result = await runIO(graph, result, { + async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step === true) + await request('step'); running(); }, + after(event) { send({ type: 'trace', phase: 'after', event }); }, + write(text) { send({ type: 'output', text }); }, read() { return request('input'); }, running, + }, true); + const displayed = view(graph, result, type); + phase = 'done'; + send({ type: 'done', result: displayed, stats: graph.stats }); +} +scope.onmessage = ({ data }) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) + return; + const message = data; + if (message['type'] === 'resume') { + if (!pending || message['id'] !== pending.id) + return; + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) + return; + const waiter = pending; + pending = undefined; + waiter.resolve(value); + return; + } + if (message['type'] === 'download' && phase === 'ready' && stored) { + send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); + return; + } + if (message['type'] === 'export' && phase === 'ready' && stored) { + try { + if (typeof message['export'] !== 'string' || message['export'].length > 512) + throw new Error('Invalid export selection'); + const format = treeFormat(message['format']); + const file = exportTree(stored.bundle, message['export'], format); + send({ type: 'exported', hash: stored.hash, export: message['export'], format, file }); + } + catch (error) { + send({ type: 'export-error', message: error instanceof Error ? error.message : String(error) }); + } + return; + } + let operation; + try { + if (message['type'] === 'load' && phase === 'empty') { + const localId = message['localId']; + if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) + throw new Error('Invalid catalogue ID'); + operation = load(bundleHash(message['hash']), localId); + } + else if (message['type'] === 'run' && phase === 'ready') { + if (typeof message['export'] !== 'string' || message['export'].length > 512) + throw new Error('Invalid export selection'); + operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], + ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), + output: outputType(message['output']), mode: message['mode'], step: message['step'] === true }); + } + else + return; // A Worker owns exactly one load and one run, no racing sessions. + } + catch (error) { + operation = Promise.reject(error); + } + operation.catch((error) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); +}; diff --git a/ext/demo-site/dist/expression.js b/ext/demo-site/dist/expression.js @@ -1,6 +1,4 @@ -import { sourceText, sourceLiteral, compiledDefinitions, compilerDescriptor, COMPILER_EXPORT } from './compiler.js'; -import { fetchBytes, fetchBundle, loadBundle, COMPILER_BUNDLE_LIMIT } from './bundle.js'; -import { list, text } from './codecs.js'; +import { sourceText } from './compiler.js'; // Use the shipped definition compiler, not a second expression parser. Preserve // relative layout and isolate the expression in a single, collision-free binding. export function expressionUnit(input, names) { @@ -11,26 +9,6 @@ export function expressionUnit(input, names) { } while (occupied.has(name) || expression.includes(name)); return { name, source: sourceText(`${name} = (\n${expression.split('\n').map(line => ' ' + line).join('\n')}\n)`) }; } -export async function compileExpression(graph, definitions, input) { - const unit = expressionUnit(input, definitions.keys()); - const base = new URL('../', import.meta.url); - const descriptor = await fetchBytes(new URL('compiler.json', base), 4096); - const { hash } = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(descriptor))); - const library = loadBundle(graph, await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT)); - const compiler = library.get(COMPILER_EXPORT); - if (compiler === undefined) - throw new Error('Missing stable compiler entrypoint'); - const environment = list(graph, [...definitions].map(([name, tree]) => graph.fork(text(graph, name), tree))); - try { - const values = compiledDefinitions(graph, graph.apply(compiler, [environment, sourceLiteral(graph, unit.source)])); - if (values.size !== 1 || !values.has(unit.name)) - throw new Error('Enter one expression, not a source unit'); - return values.get(unit.name); - } - catch (error) { - throw expressionDiagnostic(error); - } -} export function expressionDiagnostic(error) { if (!(error instanceof Error) || !/Compiler diagnostic \d+ at \d+:\d+/.test(error.message)) return error; diff --git a/ext/demo-site/dist/output-reveal.js b/ext/demo-site/dist/output-reveal.js @@ -0,0 +1,37 @@ +// Reveal only the start of a foreground result, never its whole (possibly huge) +// box. Bounds are visual-viewport coordinates, accounting for the sticky toolbar. +export function revealDelta(top, height, toolbarHeight, viewportTop, viewportHeight) { + const safeTop = viewportTop + toolbarHeight + 12; + const bottom = viewportTop + viewportHeight - 12; + if (bottom <= safeTop) + return 0; + const visibleStart = Math.min(64, height, bottom - safeTop); + return top >= safeTop && top + visibleStart <= bottom ? 0 : top - safeTop; +} +export function outputReveal(toolbar) { + let armed = false, revision = 0; + const cancel = () => { armed = false; revision++; }; + // A user who scrolls elsewhere during a slow run has chosen what to read. + window.addEventListener('pagehide', cancel); + window.addEventListener('wheel', cancel, { passive: true }); + window.addEventListener('touchmove', cancel, { passive: true }); + window.addEventListener('keydown', event => { + if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' '].includes(event.key)) + cancel(); + }); + function reveal(target) { + if (!armed) + return; + armed = false; + const current = revision; + requestAnimationFrame(() => { + if (current !== revision || !matchMedia('(max-width:740px)').matches || target.closest('[inert]') || !target.getClientRects().length) + return; + const rect = target.getBoundingClientRect(), viewport = window.visualViewport; + const delta = revealDelta(rect.top, rect.height, toolbar.getBoundingClientRect().height, viewport?.offsetTop ?? 0, viewport?.height ?? innerHeight); + if (delta) + window.scrollBy({ top: delta, behavior: 'instant' }); + }); + } + return { arm() { cancel(); armed = true; }, cancel, reveal }; +} diff --git a/ext/demo-site/dist/runtime-backends.js b/ext/demo-site/dist/runtime-backends.js @@ -1,7 +1,6 @@ export const backends = { - js: { label: 'JavaScript', persistentDefinitions: false, worker: './worker.js', compiler: './compiler-worker.js' }, - 'zig-wasm': { label: 'Zig', persistentDefinitions: true, worker: './wasm-worker.js?backend=zig-wasm', compiler: './wasm-worker.js?backend=zig-wasm' }, - 'haskell-wasm': { label: 'Haskell', persistentDefinitions: true, worker: './wasm-worker.js?backend=haskell-wasm', compiler: './wasm-worker.js?backend=haskell-wasm' }, + 'zig-wasm': { label: 'Zig', worker: './wasm-worker.js?backend=zig-wasm' }, + 'haskell-wasm': { label: 'Haskell', worker: './wasm-worker.js?backend=haskell-wasm' }, }; export function backend(value) { if (!Object.hasOwn(backends, value)) diff --git a/ext/demo-site/dist/syntax.js b/ext/demo-site/dist/syntax.js @@ -0,0 +1,200 @@ +const keywords = new Set(['let', 'in', 'where', 'do', 'case', 'of', 'rec']); +export function tokenize(source) { + // Decoration must remain cheap even for large read-only library sources. + if (source.length > 32_768) + return [{ start: 0, end: source.length, kind: '', text: source }]; + const tokens = []; + const add = (start, end, kind) => { tokens.push({ start, end, kind, text: source.slice(start, end) }); }; + let i = 0; + while (i < source.length) { + const start = i, tail = source.slice(i); + if (tail.startsWith('--')) { + const end = source.indexOf('\n', i); + i = end < 0 ? source.length : end; + add(start, i, 'comment'); + } + else if (tail.startsWith('|-')) { + const end = source.indexOf('-|', i + 2); + i = end < 0 ? source.length : end + 2; + add(start, i, 'comment'); + } + else if (source[i] === '"') { + i++; + let part = start; + while (i < source.length) { + if (source[i] === '\\') { + add(part, i, 'string'); + const escape = i; + i = Math.min(i + 1 + ((source.codePointAt(i + 1) ?? 0) > 0xffff ? 2 : 1), source.length); + add(escape, i, /^[\\][ntrfb\\"']$/.test(source.slice(escape, i)) ? 'escape' : 'invalid'); + part = i; + } + else if (source[i++] === '"') + break; + } + if (part < i) + add(part, i, 'string'); + } + else { + const match = /^(\s+|![A-Za-z]+|[A-Za-z_][A-Za-z0-9_?'$%.\-]*|[0-9]+|=@|<-|<\||\|>|.)/su.exec(tail); + const text = match[0]; + i += text.length; + const kind = /^\s/.test(text) ? '' : text === 't' ? 'tree' : keywords.has(text) ? 'keyword' + : ['!import', '!export'].includes(text) ? 'directive' : /^[0-9]/.test(text) ? 'number' + : /^[A-Za-z_]/.test(text) ? 'name' : /^[()[\]{}]$/.test(text) ? 'bracket' : 'operator'; + add(start, i, kind); + } + } + // Conservative declaration-site recognition, not semantic highlighting. + // Layout contexts distinguish local equation heads from case-arm captures. + const significant = tokens.filter(t => t.kind && t.kind !== 'comment'); + const contexts = []; + let depth = 0, previous, directiveLine = -1, line = 0; + let lineEnd = source.indexOf('\n'); + if (lineEnd < 0) + lineEnd = source.length; + let indent = /^ */.exec(source)[0].length; + for (let n = 0; n < significant.length; n++) { + const token = significant[n]; + while (lineEnd < token.start) { + line = lineEnd + 1; + const next = source.indexOf('\n', line); + lineEnd = next < 0 ? source.length : next; + indent = /^ */.exec(source.slice(line))[0].length; + } + const first = token.start === line + indent; + if (first) + while (contexts.length && indent <= contexts.at(-1).indent) + contexts.pop(); + if (/^[)\]}]$/.test(token.text)) + depth--; + while (contexts.length && depth < contexts.at(-1).depth) + contexts.pop(); + if (token.kind === 'directive') + directiveLine = line; + if (directiveLine === line && ['as', 'from'].includes(token.text)) + token.kind = 'keyword'; + if (token.kind === 'name' && ['@', '=@'].includes(previous?.text ?? '')) + token.kind = 'contract'; + const localHead = previous && ['let', 'where', 'rec'].includes(previous.text); + if (token.kind === 'name' && directiveLine !== line && (localHead || first && (indent === 0 || ['let', 'where'].includes(contexts.at(-1)?.kind ?? '')))) { + let nesting = 0; + for (let j = n + 1; j < significant.length; j++) { + const next = significant[j]; + if (next.start > lineEnd || keywords.has(next.text)) + break; + if (nesting === 0 && (next.text === '=' || next.text === '=@')) { + token.kind = 'definition'; + break; + } + if (/^[([{]$/.test(next.text)) + nesting++; + if (/^[)\]}]$/.test(next.text) && --nesting < 0) + break; + if (!nesting && [':', '<-'].includes(next.text)) + break; + } + } + if (['let', 'where', 'of'].includes(token.text)) + contexts.push({ kind: token.text, indent, depth }); + if (token.text === 'in') { + const index = contexts.map(c => c.kind).lastIndexOf('let'); + if (index >= 0) + contexts.splice(index); + } + if (/^[([{]$/.test(token.text)) + depth++; + previous = token; + } + return tokens; +} +export function bracketPair(tokens, caret) { + const brackets = tokens.filter(t => t.kind === 'bracket'); + const selected = brackets.find(t => t.start === caret) ?? brackets.find(t => t.end === caret); + if (!selected) + return []; + const stack = [], pairs = new Map(); + for (const token of brackets) { + if ('([{'.includes(token.text)) + stack.push(token); + else { + const open = stack.pop(); + if (open && '([{'.indexOf(open.text) === ')]}'.indexOf(token.text)) { + pairs.set(open.start, token.start); + pairs.set(token.start, open.start); + } + else + stack.length = 0; // Never match across malformed nesting. + } + } + const partner = pairs.get(selected.start); + return partner === undefined ? [selected.start] : [selected.start, partner]; +} +export function paintSyntax(target, tokens, pair = []) { + const fragment = document.createDocumentFragment(); + for (const token of tokens) { + if (!token.kind) { + fragment.append(document.createTextNode(token.text)); + continue; + } + const span = document.createElement('span'); + span.className = `syntax-${token.kind}`; + span.textContent = token.text; + if (pair.includes(token.start)) + span.classList.add(pair.length === 2 ? 'bracket-match' : 'bracket-unmatched'); + fragment.append(span); + } + target.replaceChildren(fragment); +} +export function syntaxEditor(input) { + const wrapper = document.createElement('div'); + wrapper.className = 'syntax-editor'; + input.before(wrapper); + wrapper.append(input); + const viewport = document.createElement('div'); + viewport.className = 'syntax-viewport'; + viewport.setAttribute('aria-hidden', 'true'); + const mirror = document.createElement('pre'); + viewport.append(mirror); + wrapper.prepend(viewport); + let value, tokens = [], lastPair = '', composing = false; + function scroll() { mirror.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`; } + function refresh() { + wrapper.hidden = input.hidden; + wrapper.classList.toggle('modified', input.classList.contains('modified')); + wrapper.classList.toggle('disabled', input.disabled); + wrapper.classList.toggle('composing', composing); + const style = getComputedStyle(input); + // Use the textarea's actual content box, excluding scrollbar and border. + mirror.style.width = `${input.clientWidth}px`; + viewport.style.inset = `${style.borderTopWidth} ${style.borderRightWidth} ${style.borderBottomWidth} ${style.borderLeftWidth}`; + for (const property of ['font-family', 'font-size', 'line-height', 'font-weight', 'font-style', 'font-stretch', 'font-variant-ligatures', 'font-kerning', 'font-feature-settings', 'font-variation-settings', 'letter-spacing', 'padding', 'tab-size', 'text-align']) + mirror.style.setProperty(property, style.getPropertyValue(property)); + const changed = value !== input.value; + if (changed) { + value = input.value; + tokens = tokenize(value); + } + const pair = !composing && document.activeElement === input && input.selectionStart === input.selectionEnd ? bracketPair(tokens, input.selectionStart) : []; + if (changed || pair.join() !== lastPair) { + paintSyntax(mirror, tokens, pair); + lastPair = pair.join(); + // Preserve a trailing empty line without introducing a new line. + if (!value || value.endsWith('\n')) + mirror.append(document.createTextNode(' ')); + } + scroll(); + } + input.classList.add('syntax-input'); + for (const event of ['input', 'click', 'keyup', 'select', 'focus', 'blur']) + input.addEventListener(event, refresh); + input.addEventListener('compositionstart', () => { composing = true; refresh(); }); + input.addEventListener('compositionend', () => { composing = false; refresh(); }); + input.addEventListener('scroll', scroll, { passive: true }); + document.addEventListener('selectionchange', () => { if (document.activeElement === input) + refresh(); }); + new ResizeObserver(refresh).observe(input); + new MutationObserver(refresh).observe(input, { attributes: true, attributeFilter: ['hidden', 'disabled', 'class'] }); + refresh(); + return refresh; +} diff --git a/ext/demo-site/dist/worker.js b/ext/demo-site/dist/worker.js @@ -1,115 +0,0 @@ -import { sourceText } from './compiler.js'; -import { compileExpression } from './expression.js'; -import { resumeValue } from './io.js'; -import { publishedBundle } from './published-bundle.js'; -import { exportTree, treeFormat } from './tree-export.js'; -import { BROWSER_BUDGET, collectionPolicy, PACKAGE_COLLECTION } from './execution.js'; -import { Graph } from './graph.js'; -import { bundleHash, loadBundle } from './bundle.js'; -import { literals, makeLiteral, outputType, view } from './codecs.js'; -import { runIO } from './io.js'; -const scope = globalThis; -const send = (message) => scope.postMessage(message); -const running = () => send({ type: 'state', state: 'running' }); -let phase = 'empty'; -let graph, exports; -let stored; -let requestId = 0; -let pending; -function request(kind) { - if (pending) - throw new Error('Driver already waiting'); - const id = ++requestId; - return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); -} -async function load(hash, localId) { - phase = 'loading'; - running(); - const loaded = await publishedBundle(hash, localId); - const { bundle, source } = loaded; - stored = loaded; - const budget = BROWSER_BUDGET; - graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); - exports = loadBundle(graph, bundle); - phase = 'ready'; - send({ type: 'loaded', hash, exports: bundle.exports, nodes: bundle.nodes.length, metadata: bundle.metadata, source }); -} -async function run(message) { - if (phase !== 'ready' || !graph || !exports) - throw new Error('Load a bundle before running'); - phase = 'running'; - running(); - const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); - if (fn === undefined) - throw new Error('Unknown export'); - if (message.mode !== 'pure' && message.mode !== 'io') - throw new Error('Unsupported execution mode'); - const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph, value)); - let result = message.expression === undefined ? graph.apply(fn, args) : fn; - graph.collect([result]); // Exclusive one-run session: only this application remains owned. - if (message.mode === 'io') - result = await runIO(graph, result, { - async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step === true) - await request('step'); running(); }, - after(event) { send({ type: 'trace', phase: 'after', event }); }, - write(text) { send({ type: 'output', text }); }, read() { return request('input'); }, running, - }, true); - const displayed = view(graph, result, type); - phase = 'done'; - send({ type: 'done', result: displayed, stats: graph.stats }); -} -scope.onmessage = ({ data }) => { - if (!data || typeof data !== 'object' || Array.isArray(data)) - return; - const message = data; - if (message['type'] === 'resume') { - if (!pending || message['id'] !== pending.id) - return; - const value = resumeValue(pending.kind, message['value']); - if (value === undefined) - return; - const waiter = pending; - pending = undefined; - waiter.resolve(value); - return; - } - if (message['type'] === 'download' && phase === 'ready' && stored) { - send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); - return; - } - if (message['type'] === 'export' && phase === 'ready' && stored) { - try { - if (typeof message['export'] !== 'string' || message['export'].length > 512) - throw new Error('Invalid export selection'); - const format = treeFormat(message['format']); - const file = exportTree(stored.bundle, message['export'], format); - send({ type: 'exported', hash: stored.hash, export: message['export'], format, file }); - } - catch (error) { - send({ type: 'export-error', message: error instanceof Error ? error.message : String(error) }); - } - return; - } - let operation; - try { - if (message['type'] === 'load' && phase === 'empty') { - const localId = message['localId']; - if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) - throw new Error('Invalid catalogue ID'); - operation = load(bundleHash(message['hash']), localId); - } - else if (message['type'] === 'run' && phase === 'ready') { - if (typeof message['export'] !== 'string' || message['export'].length > 512) - throw new Error('Invalid export selection'); - operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], - ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), - output: outputType(message['output']), mode: message['mode'], step: message['step'] === true }); - } - else - return; // A Worker owns exactly one load and one run, no racing sessions. - } - catch (error) { - operation = Promise.reject(error); - } - operation.catch((error) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); -}; diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -23,8 +23,8 @@ <section id="editor" class="workspace" aria-label="tricu editor"> <div class="editor-toolbar" data-inspector-background> <div class="toolbar-selection"><label for="program-picker">Program</label><select id="program-picker" disabled><option value="">Loading Start Here…</option></select></div> - <div class="toolbar-runtime"><label for="runtime-backend">Runtime</label><select id="runtime-backend"><option value="haskell-wasm" selected>Haskell</option><option value="zig-wasm">Zig</option><option value="js">JavaScript</option></select></div> - <div class="toolbar-actions"><button id="run" class="primary" disabled>Evaluate</button><button id="stop" disabled>Stop</button><button id="settings-toggle" aria-controls="settings-panel" aria-expanded="false">Settings</button></div> + <div class="toolbar-runtime"><label for="runtime-backend">Runtime</label><select id="runtime-backend"><option value="haskell-wasm" selected>Haskell</option><option value="zig-wasm">Zig</option></select></div> + <div class="toolbar-actions"><button id="stop" disabled hidden>Stop</button><button id="settings-toggle" aria-controls="settings-panel" aria-expanded="false">Settings</button></div> <span id="status" role="status">Loading Start Here</span> </div> <div id="editor-body" class="editor-body"> @@ -33,15 +33,30 @@ <div class="source-heading"><h2 id="source-title">Source</h2><span id="source-label">tricu / stable</span></div> <p id="authorship">Published package</p><p id="source-availability" class="hint" hidden></p> <label class="sr-only" for="source-editor">tricu source (up to 16,384 characters)</label> - <textarea id="source-editor" rows="18" maxlength="32768" spellcheck="false" hidden></textarea> + <textarea id="source-editor" rows="18" maxlength="32768" spellcheck="false" autocapitalize="off" autocorrect="off" hidden></textarea> <pre id="source-notes"><code id="source">Loading Start Here…</code></pre> <p id="compile-diagnostic" class="error" role="status" hidden></p><p id="compile-activity" class="hint" role="status" hidden></p> <div class="source-actions"><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. Evaluation is always explicit.</span></div> </section> - <section class="expression-dock" aria-label="Expression input"> - <div id="expression-inputs"><label for="expression-editor">Expression <span>· use the loaded definitions</span></label><textarea id="expression-editor" rows="3" maxlength="32768" spellcheck="false" aria-describedby="expression-help"></textarea><p id="expression-help" class="hint">Literals, applications and local bindings. No implicit prelude or browser module imports.</p></div> + <section class="expression-dock" aria-label="Evaluation"> + <div class="output-toolbar" role="group" aria-label="Evaluation settings"> + <div><label for="output-target">Output</label><select id="output-target"><option value="result">Evaluated result</option><option value="definition">Stored definition</option></select></div> + <div><label for="export" title="Select a stored definition or seed the expression with a call">Definition</label><select id="export" disabled></select></div> + <div><label for="output-type">Interpretation</label><select id="output-type"><option value="tricu">tricu · t source</option><option value="triangle">△ source</option><option value="dag">DAG · shared △</option><option value="ternary">Ternary</option><option value="string">Text</option><option value="number">Natural number</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="record">Scalar record</option></select></div> + <div><label for="mode">Execution</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div> + </div> + <div id="expression-inputs"><label for="expression-editor">Expression <span>· use the loaded definitions</span></label><textarea id="expression-editor" rows="3" maxlength="32768" spellcheck="false" autocapitalize="off" autocorrect="off" aria-describedby="expression-help"></textarea><p id="expression-help" class="hint">Literals, applications and local bindings. No implicit prelude or browser module imports.</p></div> <div id="guided-inputs" hidden><div class="argument-heading"><h3>Structured arguments</h3><button id="add-argument">Add argument</button></div><div id="argument-fields"></div></div> <p id="draft-run-note" class="hint" role="status" hidden></p> + <div class="evaluation-actions"><button id="run" class="primary" disabled>Evaluate</button><button id="stop-run" disabled>Stop</button></div> + </section> + <section class="output-panel" aria-label="Output"> + <p id="result-stale" class="hint" role="status" hidden>Previous output · source or settings changed. Evaluate or show the definition to refresh.</p> + <p id="output-provenance" class="hint" role="status"></p> + <pre id="result" tabindex="0" aria-label="Result output"></pre> + <div class="output-actions"><button id="copy-output" disabled>Copy</button><button id="download-output" disabled>Download</button><button id="next" disabled hidden>Next IO action</button></div> + <section id="io-console" aria-label="Program IO" hidden><h3>Program IO</h3><pre id="terminal" tabindex="0" aria-label="Program output"></pre><form id="input-form" hidden><label for="line-input">One line, up to 256 characters</label><div class="input-row"><input id="line-input" maxlength="512" autocomplete="off"><button type="submit">Send ↵</button></div></form></section> + <details class="trace-panel"><summary>Interaction trace</summary><p id="trace-empty" class="hint">IO actions and responses appear here.</p><ol id="trace"></ol></details> </section> </div> <aside id="settings-panel" aria-labelledby="settings-title" hidden> @@ -64,20 +79,6 @@ <details><summary>Output boundaries</summary><p class="hint">Tree results demand a complete finite tree, up to 100,000 observed nodes. Expanded files allow 32 MiB; DAG files allow 128 MiB. Incomplete snapshots are refused, never saved as complete trees. Stored definitions are read without evaluation. Stop remains available during computation; browser memory is finite.</p></details> </aside> </div> - <section class="output-panel" aria-label="Output" data-inspector-background> - <div class="output-toolbar"> - <div><label for="output-target">Output</label><select id="output-target"><option value="result">Evaluated result</option><option value="definition">Stored definition</option></select></div> - <div><label for="export" title="Select a stored definition or seed the expression with a call">Definition</label><select id="export" disabled></select></div> - <div><label for="output-type">Interpretation</label><select id="output-type"><option value="tricu">tricu · t source</option><option value="triangle">Lambada · △ source</option><option value="dag">DAG · shared △</option><option value="ternary">Ternary</option><option value="string">Text</option><option value="number">Natural number</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="record">Scalar record</option></select></div> - <div><label for="mode">Execution</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div> - </div> - <p id="result-stale" class="hint" role="status" hidden>Previous output · source or settings changed. Evaluate or show the definition to refresh.</p> - <p id="output-provenance" class="hint" role="status"></p> - <pre id="result" tabindex="0" aria-label="Result output"></pre> - <div class="output-actions"><button id="copy-output" disabled>Copy</button><button id="download-output" disabled>Download</button><button id="next" disabled hidden>Next IO action</button></div> - <section id="io-console" aria-label="Program IO" hidden><h3>Program IO</h3><pre id="terminal" tabindex="0" aria-label="Program output"></pre><form id="input-form" hidden><label for="line-input">One line, up to 256 characters</label><div class="input-row"><input id="line-input" maxlength="512" autocomplete="off"><button type="submit">Send ↵</button></div></form></section> - <details class="trace-panel"><summary>Interaction trace</summary><p id="trace-empty" class="hint">IO actions and responses appear here.</p><ol id="trace"></ol></details> - </section> </section> <div id="settings-backdrop" hidden></div> <section id="ecosystem" class="explanation wrap" data-inspector-background><p class="eyebrow">One calculus, a growing ecosystem</p><h2>Trees all the way down.</h2><div class="explanation-grid"> diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix @@ -34,6 +34,8 @@ let mkdir -p $out cp index.html style.css catalogue.json compiler.json packages.json environments.json $out/ cp -r "$TMPDIR/dist" $out/dist + # JS evaluator drivers/checkpoints are conformance tools, not a site backend. + rm -r $out/dist/conformance mkdir -p $out/runtime/zig cp ${zigRuntime}/lib/arboricx.wasm $out/runtime/zig/ cp ${../wasm/runtime.mjs} $out/runtime/zig/runtime.mjs diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,5 +1,7 @@ +import { outputReveal } from './output-reveal.js'; +import { syntaxEditor, tokenize, paintSyntax } from './syntax.js'; import { printLiteral } from './literal-input.js'; -import { backend, backends, graphStats, runtimeStats, type Backend } from './runtime-backends.js'; +import { backend, backends, runtimeStats, type Backend } from './runtime-backends.js'; import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; import { createOutput } from './output-app.js'; @@ -10,7 +12,7 @@ import { ActiveClock } from './clock.js'; import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout, type Operation } from './execution.js'; import { sourceText } from './compiler.js'; import { packageIndex, packageReleases, type Release } from './packages.js'; -import type { CompiledDraft, CompilerMessage } from './compiler-protocol.js'; +import type { CompilerMessage } from './compiler-protocol.js'; import { bundleHash, fetchBytes, type BundleHash } from './bundle.js'; import { Graph } from './graph.js'; import { line, literals, outputType, type OutputType } from './codecs.js'; @@ -22,6 +24,8 @@ const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = e const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle'); let sourceAvailable = false; const expressionEditor = el<HTMLTextAreaElement>('expression-editor'); +const refreshSourceSyntax = syntaxEditor(sourceEditor), refreshExpressionSyntax = syntaxEditor(expressionEditor); +const revealOutput = outputReveal(document.querySelector<HTMLElement>('.editor-toolbar')!); const guidedInputs = el<HTMLInputElement>('use-guided-inputs'); let editRevision = 0, composing = false, idleDue = 0; let idleTimer: ReturnType<typeof setTimeout> | undefined; @@ -36,10 +40,12 @@ function queueCompile(delay = 650): void { }, delay); } function markPreviousResult(): void { + revealOutput.cancel(); if (el('result').textContent || el('terminal').textContent) el('result-stale').hidden = false; } function presetExpression(name = exportsInput.value || preferredName): void { expressionEditor.value = [name, ...argumentsUI.read().map(printLiteral)].join(' '); + refreshExpressionSyntax(); } function expressionInput(): { expression?: string } { return guidedInputs.checked ? {} : { expression: sourceText(expressionEditor.value) }; @@ -47,11 +53,9 @@ function expressionInput(): { expression?: string } { const backendPicker = el<HTMLSelectElement>('runtime-backend'); let selectedBackend: Backend = backend(backendPicker.value); -const persistentDefinitions = () => backends[selectedBackend].persistentDefinitions; -const workerURL = (compiler = false) => new URL(compiler ? backends[selectedBackend].compiler : backends[selectedBackend].worker, import.meta.url); +const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url); let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; -let compiledDraft: CompiledDraft | undefined, compiledSource: string | undefined, compiledRevision: number | undefined; -let totalWork = 0; +let compiledSource: string | undefined, compiledRevision: number | undefined; let compilationStats = '', preferredName = 'id'; let compilerTimer: ReturnType<typeof setInterval> | undefined; function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } @@ -80,6 +84,7 @@ function syncOutputTarget(): void { } outputTarget.onchange = () => { if (busy) return; + revealOutput.cancel(); if (storedOutput()) resultInterpretation = resultCodec.value; else resultCodec.value = resultInterpretation; clearOutput(); syncOutputTarget(); @@ -130,18 +135,19 @@ function controls(working: boolean): void { sourceEditor.classList.toggle('modified', sourceModified); 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.'; } for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy; - stopButton.disabled = !busy && !draft; + stopButton.disabled = !busy && !draft; stopButton.hidden = !busy; + el<HTMLButtonElement>('stop-run').disabled = stopButton.disabled; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); expressionEditor.disabled = busy && operation !== 'compiling'; guidedInputs.disabled = busy; - el('expression-inputs').hidden = guidedInputs.checked; el('guided-inputs').hidden = !guidedInputs.checked; + el('expression-inputs').hidden = storedOutput() || guidedInputs.checked; el('guided-inputs').hidden = storedOutput() || !guidedInputs.checked; mode.disabled = busy || storedOutput(); outputTarget.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy; shareButton.disabled = busy || draft || !loaded; el<HTMLButtonElement>('reset').disabled = busy; outputUI.controls(busy); + refreshSourceSyntax(); refreshExpressionSyntax(); } -function dispose(preserveDraft = false): void { +function dispose(): void { cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; - if (!preserveDraft) { compiledDraft = undefined; compiledSource = undefined; compiledRevision = undefined; } - compiled = compiledDraft !== undefined; pending = undefined; + compiledSource = undefined; compiledRevision = undefined; compiled = false; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); } function clearOutput(): void { @@ -162,8 +168,8 @@ function finish(message: string, error = false, reason: 'failure' | 'timeout' | if (error && operation !== 'downloading') outputUI.clear(); if (operation === 'downloading') { el('bundle-download-status').textContent = message; el('bundle-download-status').classList.toggle('error', error); } const downloadOnly = operation === 'downloading'; - dispose(draft && (operation !== 'compiling' || reason === 'cancelled') && operation !== 'loading'); el('status').textContent = error ? 'Stopped' : 'Finished'; - if (!downloadOnly) { el('result').textContent = message; el('result').className = error ? 'error' : ''; } + dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; + if (!downloadOnly) { el('result').textContent = message; el('result').className = error ? 'error' : ''; revealOutput.reveal(el('result')); } for (const row of traceRows.values()) row.classList.remove('pending'); } function setPreset(entry: CatalogueEntry, name = entry.export): void { @@ -191,7 +197,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa pauseClock(); pending = { kind: data.state, id: data.id }; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; - if (data.state === 'input') lineInput.focus(); + revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); } break; case 'loaded': { @@ -215,6 +221,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa sourceAvailable = data.source !== null; el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; 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.`; + if (data.source !== null) paintSyntax(el('source'), tokenize(data.source)); if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); } else if (automaticRun && savedRun) { pauseClock(); runClock(); automaticRun = false; send(savedRun); } else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; } @@ -224,6 +231,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa pauseClock(); if (data.hash !== selection?.hash || data.export !== exportsInput.value || data.format !== resultCodec.value || !storedOutput()) break; outputUI.file(data.file, data.export, `Stored definition ${data.export} · SHA-256 ${data.hash} · ${data.format}`); + revealOutput.reveal(el('result')); operation = undefined; controls(false); el('status').textContent = 'Ready'; break; } @@ -240,11 +248,12 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa } el('trace').scrollTop = el('trace').scrollHeight; break; } - case 'output': el('terminal').textContent += data.text; el('terminal').scrollTop = el('terminal').scrollHeight; break; + case 'output': el('terminal').textContent += data.text; revealOutput.reveal(el('io-console')); el('terminal').scrollTop = el('terminal').scrollHeight; break; case 'done': { - if (persistentDefinitions()) idleDraft(); else dispose(); + idleDraft(); el('status').textContent = 'Finished'; outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`); + revealOutput.reveal(el('result')); el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`; break; } @@ -263,7 +272,7 @@ function selectExample(entry: CatalogueEntry): void { function select(entry: CatalogueEntry, release?: Release): void { sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; - dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; + revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash; sourceEditor.hidden = true; el('source-notes').hidden = false; const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id); @@ -276,7 +285,7 @@ function invalidateProgram(): void { el('bundle-download-status').textContent = ''; sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; - dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; + revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; exportsInput.replaceChildren(); sourceEditor.hidden = true; el('source-notes').hidden = false; el('source').textContent = 'Load the selected program to inspect its source or package notes.'; @@ -298,7 +307,8 @@ el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault() hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; exportsInput.onchange = () => { markPreviousResult(); preferredName = exportsInput.value; clearTreeExport(); if (storedOutput()) return; if (!scratch && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); else presetExpression(); }; runButton.onclick = () => { - if (busy) return; + if (busy || runButton.disabled) return; + revealOutput.arm(); if (storedOutput()) { if (draft || !loaded || !selection) return; const request: Extract<ClientMessage, { type: 'export' }> = { type: 'export', export: exportsInput.value, format: treeFormat(resultCodec.value) }; @@ -327,8 +337,9 @@ inputForm.onsubmit = event => { catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish(draft && compiledDraft ? 'Stopped. Last compiled draft retained.' : draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); -el<HTMLButtonElement>('reset').onclick = () => { if (busy) return; clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +stopButton.onclick = () => { revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); }; +el<HTMLButtonElement>('stop-run').onclick = () => stopButton.click(); +el<HTMLButtonElement>('reset').onclick = () => { if (busy) return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value }); @@ -469,7 +480,7 @@ compileButton.onclick = () => compileDraft(); function compileDraft(): void { if (!draft || busy) return; const snapshot = { revision: editRevision, source: sourceEditor.value }; - dispose(true); operation = 'compiling'; markPreviousResult(); clearTreeExport(); + dispose(); operation = 'compiling'; markPreviousResult(); clearTreeExport(); el('compile-diagnostic').hidden = true; try { sourceText(sourceEditor.value); @@ -483,10 +494,10 @@ function compileDraft(): void { ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } } -function draftWorker(snapshot?: { revision: number; source: string }): Worker { - const current = new Worker(workerURL(true), { type: 'module' }); worker = current; +function draftWorker(snapshot: { revision: number; source: string }): Worker { + const current = new Worker(workerURL(), { type: 'module' }); worker = current; const discardStale = (): boolean => { - if (operation !== 'compiling' || snapshot?.revision === editRevision) return false; + if (operation !== 'compiling' || snapshot.revision === editRevision) return false; dispose(); el('status').textContent = 'Changes pending'; queueCompile(Math.max(0, idleDue - performance.now())); return true; }; current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => { @@ -494,32 +505,32 @@ function draftWorker(snapshot?: { revision: number; source: string }): Worker { if ((data.type === 'compiled' || data.type === 'error') && discardStale()) return; switch (data.type) { case 'compiled': - stopCompilerActivity(); pauseClock(); operation = undefined; compiledDraft = data.draft; compiledSource = snapshot!.source; compiledRevision = snapshot!.revision; compiled = true; totalWork = graphStats(data.stats) ? data.stats.work : 0; + stopCompilerActivity(); pauseClock(); operation = undefined; compiledSource = snapshot.source; compiledRevision = snapshot.revision; compiled = true; exportsInput.replaceChildren(); for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); } if (data.names.includes(preferredName)) exportsInput.value = preferredName; - if (persistentDefinitions()) idleDraft(); else dispose(true); el('status').textContent = 'Compiled'; - compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${graphStats(data.stats) ? `${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${data.stats.work.toLocaleString()} work units` : runtimeStats(data.stats)}`; + idleDraft(); el('status').textContent = 'Compiled'; + compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`; el('compiler-stats').textContent = compilationStats; break; case 'state': if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = operation === 'compiling' ? 'Compiling' : 'Running'; } - 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'; } + 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); } break; case 'trace': { 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; } - case 'output': el('terminal').textContent += data.text; break; + case 'output': el('terminal').textContent += data.text; revealOutput.reveal(el('io-console')); break; case 'done': - if (graphStats(data.stats)) totalWork += data.stats.work; - if (persistentDefinitions()) idleDraft(); else dispose(true); el('status').textContent = 'Finished'; + idleDraft(); el('status').textContent = 'Finished'; outputUI.result(data.result, `Evaluated local draft · ${backends[selectedBackend].label}`); - el('compiler-stats').textContent = `${compilationStats} · ${graphStats(data.stats) ? `${totalWork.toLocaleString()} cumulative work` : runtimeStats(data.stats)}`; + revealOutput.reveal(el('result')); + el('compiler-stats').textContent = `${compilationStats} · ${runtimeStats(data.stats)}`; break; case 'error': - if (persistentDefinitions() && operation === 'running' && !data.fatal) { + if (operation === 'running' && !data.fatal) { idleDraft(); outputUI.clear(); el('status').textContent = 'Stopped'; - el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; + el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; revealOutput.reveal(el('result')); } else finish(data.message, true); break; } @@ -527,30 +538,27 @@ function draftWorker(snapshot?: { revision: number; source: string }): Worker { current.onerror = event => { if (worker === current) { event.preventDefault(); if (!discardStale()) finish(event.message || 'Compiler Worker failed.', true); } }; return current; } -// Persistent backends retain definitions only in their live Worker. JS may -// restore its backend-private checkpoint; no checkpoint crosses backend types. +// Definitions exist only in the owning live Worker; stopping discards them. function idleDraft(): void { stopCompilerActivity(); pauseClock(); operation = undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); } function keepDraft(): void { - if (persistentDefinitions() && worker && compiled && !busy) idleDraft(); - else dispose(true); + if (worker && compiled && !busy) idleDraft(); + else dispose(); } function runDraft(): void { if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) return; try { const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); const expression = expressionInput(); - if (!persistentDefinitions()) dispose(true); - else if (!worker || busy) return; + if (!worker || busy) return; operation = 'running'; clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; - const current = persistentDefinitions() ? worker! : draftWorker(); - if (!persistentDefinitions()) current.postMessage({ type: 'restore', draft: compiledDraft }); + const current = worker; current.postMessage({ type: 'run', ...expression, export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); } catch (error) { outputUI.clear(); el('status').textContent = 'Stopped'; - el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; + el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; revealOutput.reveal(el('result')); keepDraft(); } } @@ -569,7 +577,7 @@ const examples = { for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => { const example = examples[button.dataset['compilerExample'] as keyof typeof examples]; if (!example) return; chooseProgram(''); - keepDraft(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; + revealOutput.cancel(); keepDraft(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; if (!compiled) exportsInput.replaceChildren(); editRevision++; preferredName = example.name; sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source; @@ -587,7 +595,7 @@ function loadFragment(): boolean { else { chooseProgram('hash'); showRelease(); draft = false; scratch = false; originalSource = undefined; sourceAvailable = false; - dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined; + revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined; argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure'; selection = { hash }; hashInput.value = hash; newWorker(false); } diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts @@ -1,11 +1,8 @@ import type { RuntimeStats } from './runtime-backends.js'; -import type { GraphCheckpoint, NodeId } from './graph.js'; import type { OutputType } from './codecs.js'; import type { RequestId, WorkerMessage } from './protocol.js'; -export interface CompiledDraft { graph: GraphCheckpoint; exports: Array<[string, NodeId]>; } export type CompilerClient = { type: 'compile'; source: unknown; environment?: string } - | { type: 'restore'; draft: CompiledDraft } | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'resume'; id: RequestId; value?: string }; export type CompilerMessage = Exclude<WorkerMessage, { type: 'loaded' }> - | { type: 'compiled'; names: string[]; draft?: CompiledDraft; stats: RuntimeStats; milliseconds: number }; + | { type: 'compiled'; names: string[]; stats: RuntimeStats; milliseconds: number }; diff --git a/ext/demo-site/src/compiler-worker.ts b/ext/demo-site/src/compiler-worker.ts @@ -1,105 +0,0 @@ -import { compileExpression } from './expression.js'; -import { resumeValue } from './io.js'; -import { environmentProfile } from './packages.js'; -import { BROWSER_BUDGET, collectionPolicy } from './execution.js'; -import { contractEnvironment } from './contracts.js'; -import { Graph, type NodeId } from './graph.js'; -import { COMPILER_BUNDLE_LIMIT, fetchBundle, fetchBytes, loadBundle } from './bundle.js'; -import { literals, makeLiteral, outputType, view, list, text } from './codecs.js'; -import { runIO } from './io.js'; -import { COMPILER_EXPORT, compilerDescriptor, sourceLiteral, sourceText, compiledDefinitions } from './compiler.js'; -import type { CompilerClient, CompilerMessage } from './compiler-protocol.js'; -import type { RequestId } from './protocol.js'; -const scope = globalThis as unknown as { postMessage(message: CompilerMessage): void; onmessage: ((event: MessageEvent<unknown>) => void) | null }; -const base = new URL('../', import.meta.url); -const send = (message: CompilerMessage): void => scope.postMessage(message); -const running = (): void => send({ type: 'state', state: 'running' }); -let phase: 'empty' | 'compiling' | 'ready' | 'running' | 'done' = 'empty'; -let graph: Graph | undefined, exports: Map<string, NodeId> | undefined; -let requestId = 0; -let pending: { id: RequestId; kind: 'step' | 'input'; resolve(value: string): void; reject(error: Error): void } | undefined; -function request(kind: 'step' | 'input'): Promise<string> { - if (pending) throw new Error('Driver already waiting'); - const id = ++requestId as RequestId; - return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); -} -async function compile(source: unknown, profileName?: string): Promise<void> { - phase = 'compiling'; running(); const start = performance.now(); - const budget = BROWSER_BUDGET; - graph = new Graph(budget, collectionPolicy(budget)); - const input = sourceLiteral(graph, source); // Validate before downloading a compiler. - const bytes = await fetchBytes(new URL('compiler.json', base), 4096); - const raw: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); - const { hash } = compilerDescriptor(raw); - const bundle = await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT); - const library = loadBundle(graph, bundle), compiler = library.get(COMPILER_EXPORT); - if (compiler === undefined) throw new Error('Missing stable compiler entrypoint'); - let environment: NodeId; - if (profileName === undefined) environment = contractEnvironment(graph, library); - else { - const bytes = await fetchBytes(new URL('environments.json', base), 32_768); - const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown, profileName); - if (selected.compiler !== hash) throw new Error('Demo environment/compiler pin mismatch'); - const bindings: NodeId[] = []; - if (selected.profile) { - const support = await fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, base), selected.profile.hash); - const values = loadBundle(graph, support); - for (const name of selected.profile.names) { - const tree = values.get(name); - if (tree === undefined) throw new Error(`Missing demo environment binding ${name}`); - bindings.push(graph.fork(text(graph, name), tree)); - } - } - environment = list(graph, bindings); - } - exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input])); - graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots. - phase = 'ready'; send({ type: 'compiled', names: [...exports.keys()], draft: { graph: graph.checkpoint(), exports: [...exports] }, stats: graph.stats, milliseconds: performance.now() - start }); -} -async function run(message: Extract<CompilerClient, { type: 'run' }>): Promise<void> { - if (!graph || !exports) throw new Error('Compile a unit first'); - phase = 'running'; running(); - const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); - if (fn === undefined) throw new Error('Unknown compiled definition'); - if (message.mode !== 'pure' && message.mode !== 'io') throw new Error('Unsupported execution mode'); - const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph!, value)); - let result = message.expression === undefined ? graph.apply(fn, args) : fn; - if (message.mode === 'io') result = await runIO(graph, result, { - async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step) await request('step'); running(); }, - after(event) { send({ type: 'trace', phase: 'after', event }); }, write(text) { send({ type: 'output', text }); }, - read() { return request('input'); }, running, - }, true, exports.values()); - const displayed = view(graph, result, type); - graph.collect(exports.values()); // Drop this run's temporaries, retaining every definition. - phase = 'ready'; send({ type: 'done', result: displayed, stats: graph.stats }); -} -scope.onmessage = ({ data }) => { - if (!data || typeof data !== 'object' || Array.isArray(data)) return; - const message = data as Record<string, unknown>; - if (message['type'] === 'resume') { - if (!pending || message['id'] !== pending.id) return; - const value = resumeValue(pending.kind, message['value']); - if (value === undefined) return; - const waiter = pending; pending = undefined; waiter.resolve(value); - return; - } - let operation: Promise<void>; - try { - if (message['type'] === 'restore' && phase === 'empty') { - // Only the owning page supplies this trusted in-memory checkpoint. - const restored = data as Extract<CompilerClient, { type: 'restore' }>; - const budget = BROWSER_BUDGET; - graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); - exports = new Map(restored.draft.exports); phase = 'ready'; return; - } else if (message['type'] === 'compile' && phase === 'empty') { - if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment'); - operation = compile(message['source'], message['environment'] as string | undefined); - } else if (message['type'] === 'run' && phase === 'ready') { - if (typeof message['export'] !== 'string' || message['export'].length > 128) throw new Error('Invalid compiled selection'); - operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], - ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), - output: outputType(message['output']), mode: message['mode'] as 'pure' | 'io', step: message['step'] === true }); - } else return; - } catch (error) { operation = Promise.reject(error); } - operation.catch((error: unknown) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); -}; diff --git a/ext/demo-site/src/conformance/compiler-protocol.ts b/ext/demo-site/src/conformance/compiler-protocol.ts @@ -0,0 +1,12 @@ +// Private JS checkpoint protocol for conformance tests only. +import type { RuntimeStats } from '../runtime-backends.js'; +import type { GraphCheckpoint, NodeId } from '../graph.js'; +import type { OutputType } from '../codecs.js'; +import type { RequestId, WorkerMessage } from '../protocol.js'; +export interface CompiledDraft { graph: GraphCheckpoint; exports: Array<[string, NodeId]>; } +export type CompilerClient = { type: 'compile'; source: unknown; environment?: string } + | { type: 'restore'; draft: CompiledDraft } + | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } + | { type: 'resume'; id: RequestId; value?: string }; +export type CompilerMessage = Exclude<WorkerMessage, { type: 'loaded' }> + | { type: 'compiled'; names: string[]; draft?: CompiledDraft; stats: RuntimeStats; milliseconds: number }; diff --git a/ext/demo-site/src/conformance/compiler-worker.ts b/ext/demo-site/src/conformance/compiler-worker.ts @@ -0,0 +1,106 @@ +// Test-only JS evaluator driver; excluded from the production site. +import { compileExpression } from './expression.js'; +import { resumeValue } from '../io.js'; +import { environmentProfile } from '../packages.js'; +import { BROWSER_BUDGET, collectionPolicy } from '../execution.js'; +import { contractEnvironment } from '../contracts.js'; +import { Graph, type NodeId } from '../graph.js'; +import { COMPILER_BUNDLE_LIMIT, fetchBundle, fetchBytes, loadBundle } from '../bundle.js'; +import { literals, makeLiteral, outputType, view, list, text } from '../codecs.js'; +import { runIO } from '../io.js'; +import { COMPILER_EXPORT, compilerDescriptor, sourceLiteral, sourceText, compiledDefinitions } from '../compiler.js'; +import type { CompilerClient, CompilerMessage } from './compiler-protocol.js'; +import type { RequestId } from '../protocol.js'; +const scope = globalThis as unknown as { postMessage(message: CompilerMessage): void; onmessage: ((event: MessageEvent<unknown>) => void) | null }; +const base = new URL('../../', import.meta.url); +const send = (message: CompilerMessage): void => scope.postMessage(message); +const running = (): void => send({ type: 'state', state: 'running' }); +let phase: 'empty' | 'compiling' | 'ready' | 'running' | 'done' = 'empty'; +let graph: Graph | undefined, exports: Map<string, NodeId> | undefined; +let requestId = 0; +let pending: { id: RequestId; kind: 'step' | 'input'; resolve(value: string): void; reject(error: Error): void } | undefined; +function request(kind: 'step' | 'input'): Promise<string> { + if (pending) throw new Error('Driver already waiting'); + const id = ++requestId as RequestId; + return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); +} +async function compile(source: unknown, profileName?: string): Promise<void> { + phase = 'compiling'; running(); const start = performance.now(); + const budget = BROWSER_BUDGET; + graph = new Graph(budget, collectionPolicy(budget)); + const input = sourceLiteral(graph, source); // Validate before downloading a compiler. + const bytes = await fetchBytes(new URL('compiler.json', base), 4096); + const raw: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + const { hash } = compilerDescriptor(raw); + const bundle = await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT); + const library = loadBundle(graph, bundle), compiler = library.get(COMPILER_EXPORT); + if (compiler === undefined) throw new Error('Missing stable compiler entrypoint'); + let environment: NodeId; + if (profileName === undefined) environment = contractEnvironment(graph, library); + else { + const bytes = await fetchBytes(new URL('environments.json', base), 32_768); + const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown, profileName); + if (selected.compiler !== hash) throw new Error('Demo environment/compiler pin mismatch'); + const bindings: NodeId[] = []; + if (selected.profile) { + const support = await fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, base), selected.profile.hash); + const values = loadBundle(graph, support); + for (const name of selected.profile.names) { + const tree = values.get(name); + if (tree === undefined) throw new Error(`Missing demo environment binding ${name}`); + bindings.push(graph.fork(text(graph, name), tree)); + } + } + environment = list(graph, bindings); + } + exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input])); + graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots. + phase = 'ready'; send({ type: 'compiled', names: [...exports.keys()], draft: { graph: graph.checkpoint(), exports: [...exports] }, stats: graph.stats, milliseconds: performance.now() - start }); +} +async function run(message: Extract<CompilerClient, { type: 'run' }>): Promise<void> { + if (!graph || !exports) throw new Error('Compile a unit first'); + phase = 'running'; running(); + const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); + if (fn === undefined) throw new Error('Unknown compiled definition'); + if (message.mode !== 'pure' && message.mode !== 'io') throw new Error('Unsupported execution mode'); + const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph!, value)); + let result = message.expression === undefined ? graph.apply(fn, args) : fn; + if (message.mode === 'io') result = await runIO(graph, result, { + async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step) await request('step'); running(); }, + after(event) { send({ type: 'trace', phase: 'after', event }); }, write(text) { send({ type: 'output', text }); }, + read() { return request('input'); }, running, + }, true, exports.values()); + const displayed = view(graph, result, type); + graph.collect(exports.values()); // Drop this run's temporaries, retaining every definition. + phase = 'ready'; send({ type: 'done', result: displayed, stats: graph.stats }); +} +scope.onmessage = ({ data }) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) return; + const message = data as Record<string, unknown>; + if (message['type'] === 'resume') { + if (!pending || message['id'] !== pending.id) return; + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) return; + const waiter = pending; pending = undefined; waiter.resolve(value); + return; + } + let operation: Promise<void>; + try { + if (message['type'] === 'restore' && phase === 'empty') { + // Only the owning conformance harness supplies this trusted in-memory checkpoint. + const restored = data as Extract<CompilerClient, { type: 'restore' }>; + const budget = BROWSER_BUDGET; + graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); + exports = new Map(restored.draft.exports); phase = 'ready'; return; + } else if (message['type'] === 'compile' && phase === 'empty') { + if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment'); + operation = compile(message['source'], message['environment'] as string | undefined); + } else if (message['type'] === 'run' && phase === 'ready') { + if (typeof message['export'] !== 'string' || message['export'].length > 128) throw new Error('Invalid compiled selection'); + operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], + ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), + output: outputType(message['output']), mode: message['mode'] as 'pure' | 'io', step: message['step'] === true }); + } else return; + } catch (error) { operation = Promise.reject(error); } + operation.catch((error: unknown) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); +}; diff --git a/ext/demo-site/src/conformance/expression.ts b/ext/demo-site/src/conformance/expression.ts @@ -0,0 +1,21 @@ +import { sourceLiteral, compiledDefinitions, compilerDescriptor, COMPILER_EXPORT } from '../compiler.js'; +import { fetchBytes, fetchBundle, loadBundle, COMPILER_BUNDLE_LIMIT } from '../bundle.js'; +import { list, text } from '../codecs.js'; +import type { Graph, NodeId } from '../graph.js'; + +import { expressionUnit, expressionDiagnostic } from '../expression.js'; +export async function compileExpression(graph: Graph, definitions: ReadonlyMap<string, NodeId>, input: unknown): Promise<NodeId> { + const unit = expressionUnit(input, definitions.keys()); + const base = new URL('../../', import.meta.url); + const descriptor = await fetchBytes(new URL('compiler.json', base), 4096); + const { hash } = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(descriptor)) as unknown); + const library = loadBundle(graph, await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT)); + const compiler = library.get(COMPILER_EXPORT); + if (compiler === undefined) throw new Error('Missing stable compiler entrypoint'); + const environment = list(graph, [...definitions].map(([name, tree]) => graph.fork(text(graph, name), tree))); + try { + const values = compiledDefinitions(graph, graph.apply(compiler, [environment, sourceLiteral(graph, unit.source)])); + if (values.size !== 1 || !values.has(unit.name)) throw new Error('Enter one expression, not a source unit'); + return values.get(unit.name)!; + } catch (error) { throw expressionDiagnostic(error); } +} diff --git a/ext/demo-site/src/conformance/worker.ts b/ext/demo-site/src/conformance/worker.ts @@ -0,0 +1,89 @@ +// Test-only JS evaluator driver; excluded from the production site. +import { sourceText } from '../compiler.js'; +import { compileExpression } from './expression.js'; +import { resumeValue } from '../io.js'; +import { publishedBundle } from '../published-bundle.js'; +import { exportTree, treeFormat } from '../tree-export.js'; +import { BROWSER_BUDGET, collectionPolicy, PACKAGE_COLLECTION } from '../execution.js'; +import { Graph } from '../graph.js'; +import { bundleHash, loadBundle, type Bundle, type BundleHash } from '../bundle.js'; +import { literals, makeLiteral, outputType, view } from '../codecs.js'; +import { runIO } from '../io.js'; +import type { ClientMessage, WorkerMessage, RequestId } from '../protocol.js'; +const scope = globalThis as unknown as { postMessage(message: WorkerMessage): void; onmessage: ((event: MessageEvent<unknown>) => void) | null }; +const send = (message: WorkerMessage): void => scope.postMessage(message); +const running = (): void => send({ type: 'state', state: 'running' }); +let phase: 'empty' | 'loading' | 'ready' | 'running' | 'done' = 'empty'; +let graph: Graph | undefined, exports: Map<string, import('../graph.js').NodeId> | undefined; +let stored: { hash: BundleHash; bundle: Bundle; bytes: Uint8Array } | undefined; +let requestId = 0; +let pending: { id: RequestId; kind: 'step' | 'input'; resolve(value: string): void; reject(error: Error): void } | undefined; +function request(kind: 'step' | 'input'): Promise<string> { + if (pending) throw new Error('Driver already waiting'); + const id = ++requestId as RequestId; + return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); +} +async function load(hash: BundleHash, localId: string | undefined): Promise<void> { + phase = 'loading'; running(); + const loaded = await publishedBundle(hash, localId); + const { bundle, source } = loaded; + stored = loaded; + const budget = BROWSER_BUDGET; + graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); exports = loadBundle(graph, bundle); + phase = 'ready'; + send({ type: 'loaded', hash, exports: bundle.exports, nodes: bundle.nodes.length, metadata: bundle.metadata, source }); +} +async function run(message: Extract<ClientMessage, { type: 'run' }>): Promise<void> { + if (phase !== 'ready' || !graph || !exports) throw new Error('Load a bundle before running'); + phase = 'running'; running(); + const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); + if (fn === undefined) throw new Error('Unknown export'); + if (message.mode !== 'pure' && message.mode !== 'io') throw new Error('Unsupported execution mode'); + const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph!, value)); + let result = message.expression === undefined ? graph.apply(fn, args) : fn; + graph.collect([result]); // Exclusive one-run session: only this application remains owned. + if (message.mode === 'io') result = await runIO(graph, result, { + async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step === true) await request('step'); running(); }, + after(event) { send({ type: 'trace', phase: 'after', event }); }, + write(text) { send({ type: 'output', text }); }, read() { return request('input'); }, running, + }, true); + const displayed = view(graph, result, type); + phase = 'done'; send({ type: 'done', result: displayed, stats: graph.stats }); +} +scope.onmessage = ({ data }) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) return; + const message = data as Record<string, unknown>; + if (message['type'] === 'resume') { + if (!pending || message['id'] !== pending.id) return; + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) return; + const waiter = pending; pending = undefined; waiter.resolve(value); + return; + } + if (message['type'] === 'download' && phase === 'ready' && stored) { + send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); return; + } + if (message['type'] === 'export' && phase === 'ready' && stored) { + try { + if (typeof message['export'] !== 'string' || message['export'].length > 512) throw new Error('Invalid export selection'); + const format = treeFormat(message['format']); + const file = exportTree(stored.bundle, message['export'], format); + send({ type: 'exported', hash: stored.hash, export: message['export'], format, file }); + } catch (error) { send({ type: 'export-error', message: error instanceof Error ? error.message : String(error) }); } + return; + } + let operation: Promise<void>; + try { + if (message['type'] === 'load' && phase === 'empty') { + const localId = message['localId']; + if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) throw new Error('Invalid catalogue ID'); + operation = load(bundleHash(message['hash']), localId); + } else if (message['type'] === 'run' && phase === 'ready') { + if (typeof message['export'] !== 'string' || message['export'].length > 512) throw new Error('Invalid export selection'); + operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], + ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), + output: outputType(message['output']), mode: message['mode'] as 'pure' | 'io', step: message['step'] === true }); + } else return; // A Worker owns exactly one load and one run, no racing sessions. + } catch (error) { operation = Promise.reject(error); } + operation.catch((error: unknown) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); +}; diff --git a/ext/demo-site/src/expression.ts b/ext/demo-site/src/expression.ts @@ -1,7 +1,4 @@ -import { sourceText, sourceLiteral, compiledDefinitions, compilerDescriptor, COMPILER_EXPORT } from './compiler.js'; -import { fetchBytes, fetchBundle, loadBundle, COMPILER_BUNDLE_LIMIT } from './bundle.js'; -import { list, text } from './codecs.js'; -import type { Graph, NodeId } from './graph.js'; +import { sourceText } from './compiler.js'; // Use the shipped definition compiler, not a second expression parser. Preserve // relative layout and isolate the expression in a single, collision-free binding. @@ -11,22 +8,6 @@ export function expressionUnit(input: unknown, names: Iterable<string>): { name: do { name = `browserExpression${i++}`; } while (occupied.has(name) || expression.includes(name)); return { name, source: sourceText(`${name} = (\n${expression.split('\n').map(line => ' ' + line).join('\n')}\n)`) }; } -export async function compileExpression(graph: Graph, definitions: ReadonlyMap<string, NodeId>, input: unknown): Promise<NodeId> { - const unit = expressionUnit(input, definitions.keys()); - const base = new URL('../', import.meta.url); - const descriptor = await fetchBytes(new URL('compiler.json', base), 4096); - const { hash } = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(descriptor)) as unknown); - const library = loadBundle(graph, await fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`, base), hash, COMPILER_BUNDLE_LIMIT)); - const compiler = library.get(COMPILER_EXPORT); - if (compiler === undefined) throw new Error('Missing stable compiler entrypoint'); - const environment = list(graph, [...definitions].map(([name, tree]) => graph.fork(text(graph, name), tree))); - try { - const values = compiledDefinitions(graph, graph.apply(compiler, [environment, sourceLiteral(graph, unit.source)])); - if (values.size !== 1 || !values.has(unit.name)) throw new Error('Enter one expression, not a source unit'); - return values.get(unit.name)!; - } catch (error) { throw expressionDiagnostic(error); } -} - export function expressionDiagnostic(error: unknown): unknown { if (!(error instanceof Error) || !/Compiler diagnostic \d+ at \d+:\d+/.test(error.message)) return error; return new Error(error.message.replace(/(Compiler diagnostic \d+) at (\d+):(\d+)/, diff --git a/ext/demo-site/src/output-reveal.ts b/ext/demo-site/src/output-reveal.ts @@ -0,0 +1,32 @@ +// Reveal only the start of a foreground result, never its whole (possibly huge) +// box. Bounds are visual-viewport coordinates, accounting for the sticky toolbar. +export function revealDelta(top: number, height: number, toolbarHeight: number, viewportTop: number, viewportHeight: number): number { + const safeTop = viewportTop + toolbarHeight + 12; + const bottom = viewportTop + viewportHeight - 12; + if (bottom <= safeTop) return 0; + const visibleStart = Math.min(64, height, bottom - safeTop); + return top >= safeTop && top + visibleStart <= bottom ? 0 : top - safeTop; +} + +export function outputReveal(toolbar: HTMLElement) { + let armed = false, revision = 0; + const cancel = (): void => { armed = false; revision++; }; + // A user who scrolls elsewhere during a slow run has chosen what to read. + window.addEventListener('pagehide', cancel); + window.addEventListener('wheel', cancel, { passive: true }); + window.addEventListener('touchmove', cancel, { passive: true }); + window.addEventListener('keydown', event => { + if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' '].includes(event.key)) cancel(); + }); + function reveal(target: HTMLElement): void { + if (!armed) return; + armed = false; const current = revision; + requestAnimationFrame(() => { + if (current !== revision || !matchMedia('(max-width:740px)').matches || target.closest('[inert]') || !target.getClientRects().length) return; + const rect = target.getBoundingClientRect(), viewport = window.visualViewport; + const delta = revealDelta(rect.top, rect.height, toolbar.getBoundingClientRect().height, viewport?.offsetTop ?? 0, viewport?.height ?? innerHeight); + if (delta) window.scrollBy({ top: delta, behavior: 'instant' }); + }); + } + return { arm(): void { cancel(); armed = true; }, cancel, reveal }; +} diff --git a/ext/demo-site/src/runtime-backends.ts b/ext/demo-site/src/runtime-backends.ts @@ -1,10 +1,9 @@ import type { GraphStats } from './graph.js'; -export type Backend = 'js' | 'haskell-wasm' | 'zig-wasm'; +export type Backend = 'haskell-wasm' | 'zig-wasm'; export type RuntimeStats = GraphStats | { backend: string; linearMemoryBytes: number }; export const backends = { - js: { label: 'JavaScript', persistentDefinitions: false, worker: './worker.js', compiler: './compiler-worker.js' }, - 'zig-wasm': { label: 'Zig', persistentDefinitions: true, worker: './wasm-worker.js?backend=zig-wasm', compiler: './wasm-worker.js?backend=zig-wasm' }, - 'haskell-wasm': { label: 'Haskell', persistentDefinitions: true, worker: './wasm-worker.js?backend=haskell-wasm', compiler: './wasm-worker.js?backend=haskell-wasm' }, + 'zig-wasm': { label: 'Zig', worker: './wasm-worker.js?backend=zig-wasm' }, + 'haskell-wasm': { label: 'Haskell', worker: './wasm-worker.js?backend=haskell-wasm' }, } as const; export function backend(value: string): Backend { if (!Object.hasOwn(backends, value)) throw new Error('Unknown runtime backend'); diff --git a/ext/demo-site/src/syntax.ts b/ext/demo-site/src/syntax.ts @@ -0,0 +1,138 @@ +// Presentation only: never used for parsing, compilation, or name resolution. +// Offsets are UTF-16 textarea offsets, not compiler Unicode-scalar positions. +export interface Token { start: number; end: number; kind: string; text: string } +const keywords = new Set(['let', 'in', 'where', 'do', 'case', 'of', 'rec']); +export function tokenize(source: string): Token[] { + // Decoration must remain cheap even for large read-only library sources. + if (source.length > 32_768) return [{ start: 0, end: source.length, kind: '', text: source }]; + const tokens: Token[] = []; + const add = (start: number, end: number, kind: string): void => { tokens.push({ start, end, kind, text: source.slice(start, end) }); }; + let i = 0; + while (i < source.length) { + const start = i, tail = source.slice(i); + if (tail.startsWith('--')) { const end = source.indexOf('\n', i); i = end < 0 ? source.length : end; add(start, i, 'comment'); } + else if (tail.startsWith('|-')) { const end = source.indexOf('-|', i + 2); i = end < 0 ? source.length : end + 2; add(start, i, 'comment'); } + else if (source[i] === '"') { + i++; let part = start; + while (i < source.length) { + if (source[i] === '\\') { + add(part, i, 'string'); const escape = i; i = Math.min(i + 1 + ((source.codePointAt(i + 1) ?? 0) > 0xffff ? 2 : 1), source.length); + add(escape, i, /^[\\][ntrfb\\"']$/.test(source.slice(escape, i)) ? 'escape' : 'invalid'); part = i; + } else if (source[i++] === '"') break; + } + if (part < i) add(part, i, 'string'); + } else { + const match = /^(\s+|![A-Za-z]+|[A-Za-z_][A-Za-z0-9_?'$%.\-]*|[0-9]+|=@|<-|<\||\|>|.)/su.exec(tail)!; + const text = match[0]; i += text.length; + const kind = /^\s/.test(text) ? '' : text === 't' ? 'tree' : keywords.has(text) ? 'keyword' + : ['!import', '!export'].includes(text) ? 'directive' : /^[0-9]/.test(text) ? 'number' + : /^[A-Za-z_]/.test(text) ? 'name' : /^[()[\]{}]$/.test(text) ? 'bracket' : 'operator'; + add(start, i, kind); + } + } + // Conservative declaration-site recognition, not semantic highlighting. + // Layout contexts distinguish local equation heads from case-arm captures. + const significant = tokens.filter(t => t.kind && t.kind !== 'comment'); + const contexts: { kind: string; indent: number; depth: number }[] = []; + let depth = 0, previous: Token | undefined, directiveLine = -1, line = 0; + let lineEnd = source.indexOf('\n'); if (lineEnd < 0) lineEnd = source.length; + let indent = /^ */.exec(source)![0].length; + for (let n = 0; n < significant.length; n++) { + const token = significant[n]!; + while (lineEnd < token.start) { + line = lineEnd + 1; const next = source.indexOf('\n', line); lineEnd = next < 0 ? source.length : next; + indent = /^ */.exec(source.slice(line))![0].length; + } + const first = token.start === line + indent; + if (first) while (contexts.length && indent <= contexts.at(-1)!.indent) contexts.pop(); + if (/^[)\]}]$/.test(token.text)) depth--; + while (contexts.length && depth < contexts.at(-1)!.depth) contexts.pop(); + if (token.kind === 'directive') directiveLine = line; + if (directiveLine === line && ['as', 'from'].includes(token.text)) token.kind = 'keyword'; + if (token.kind === 'name' && ['@', '=@'].includes(previous?.text ?? '')) token.kind = 'contract'; + const localHead = previous && ['let', 'where', 'rec'].includes(previous.text); + if (token.kind === 'name' && directiveLine !== line && (localHead || first && (indent === 0 || ['let', 'where'].includes(contexts.at(-1)?.kind ?? '')))) { + let nesting = 0; + for (let j = n + 1; j < significant.length; j++) { + const next = significant[j]!; + if (next.start > lineEnd || keywords.has(next.text)) break; + if (nesting === 0 && (next.text === '=' || next.text === '=@')) { token.kind = 'definition'; break; } + if (/^[([{]$/.test(next.text)) nesting++; + if (/^[)\]}]$/.test(next.text) && --nesting < 0) break; + if (!nesting && [':', '<-'].includes(next.text)) break; + } + } + if (['let', 'where', 'of'].includes(token.text)) contexts.push({ kind: token.text, indent, depth }); + if (token.text === 'in') { const index = contexts.map(c => c.kind).lastIndexOf('let'); if (index >= 0) contexts.splice(index); } + if (/^[([{]$/.test(token.text)) depth++; + previous = token; + } + return tokens; +} + +export function bracketPair(tokens: Token[], caret: number): number[] { + const brackets = tokens.filter(t => t.kind === 'bracket'); + const selected = brackets.find(t => t.start === caret) ?? brackets.find(t => t.end === caret); + if (!selected) return []; + const stack: Token[] = [], pairs = new Map<number, number>(); + for (const token of brackets) { + if ('([{'.includes(token.text)) stack.push(token); + else { + const open = stack.pop(); + if (open && '([{'.indexOf(open.text) === ')]}'.indexOf(token.text)) { + pairs.set(open.start, token.start); pairs.set(token.start, open.start); + } else stack.length = 0; // Never match across malformed nesting. + } + } + const partner = pairs.get(selected.start); + return partner === undefined ? [selected.start] : [selected.start, partner]; +} + +export function paintSyntax(target: HTMLElement, tokens: Token[], pair: number[] = []): void { + const fragment = document.createDocumentFragment(); + for (const token of tokens) { + if (!token.kind) { fragment.append(document.createTextNode(token.text)); continue; } + const span = document.createElement('span'); span.className = `syntax-${token.kind}`; span.textContent = token.text; + if (pair.includes(token.start)) span.classList.add(pair.length === 2 ? 'bracket-match' : 'bracket-unmatched'); + fragment.append(span); + } + target.replaceChildren(fragment); +} + +export function syntaxEditor(input: HTMLTextAreaElement): () => void { + const wrapper = document.createElement('div'); wrapper.className = 'syntax-editor'; + input.before(wrapper); wrapper.append(input); + const viewport = document.createElement('div'); viewport.className = 'syntax-viewport'; viewport.setAttribute('aria-hidden', 'true'); + const mirror = document.createElement('pre'); viewport.append(mirror); wrapper.prepend(viewport); + let value: string | undefined, tokens: Token[] = [], lastPair = '', composing = false; + function scroll(): void { mirror.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`; } + function refresh(): void { + wrapper.hidden = input.hidden; + wrapper.classList.toggle('modified', input.classList.contains('modified')); + wrapper.classList.toggle('disabled', input.disabled); + wrapper.classList.toggle('composing', composing); + const style = getComputedStyle(input); + // Use the textarea's actual content box, excluding scrollbar and border. + mirror.style.width = `${input.clientWidth}px`; + viewport.style.inset = `${style.borderTopWidth} ${style.borderRightWidth} ${style.borderBottomWidth} ${style.borderLeftWidth}`; + for (const property of ['font-family', 'font-size', 'line-height', 'font-weight', 'font-style', 'font-stretch', 'font-variant-ligatures', 'font-kerning', 'font-feature-settings', 'font-variation-settings', 'letter-spacing', 'padding', 'tab-size', 'text-align']) mirror.style.setProperty(property, style.getPropertyValue(property)); + const changed = value !== input.value; + if (changed) { value = input.value; tokens = tokenize(value); } + const pair = !composing && document.activeElement === input && input.selectionStart === input.selectionEnd ? bracketPair(tokens, input.selectionStart) : []; + if (changed || pair.join() !== lastPair) { + paintSyntax(mirror, tokens, pair); lastPair = pair.join(); + // Preserve a trailing empty line without introducing a new line. + if (!value || value.endsWith('\n')) mirror.append(document.createTextNode(' ')); + } + scroll(); + } + input.classList.add('syntax-input'); + for (const event of ['input', 'click', 'keyup', 'select', 'focus', 'blur']) input.addEventListener(event, refresh); + input.addEventListener('compositionstart', () => { composing = true; refresh(); }); + input.addEventListener('compositionend', () => { composing = false; refresh(); }); + input.addEventListener('scroll', scroll, { passive: true }); + document.addEventListener('selectionchange', () => { if (document.activeElement === input) refresh(); }); + new ResizeObserver(refresh).observe(input); + new MutationObserver(refresh).observe(input, { attributes: true, attributeFilter: ['hidden', 'disabled', 'class'] }); + refresh(); return refresh; +} diff --git a/ext/demo-site/src/worker.ts b/ext/demo-site/src/worker.ts @@ -1,88 +0,0 @@ -import { sourceText } from './compiler.js'; -import { compileExpression } from './expression.js'; -import { resumeValue } from './io.js'; -import { publishedBundle } from './published-bundle.js'; -import { exportTree, treeFormat } from './tree-export.js'; -import { BROWSER_BUDGET, collectionPolicy, PACKAGE_COLLECTION } from './execution.js'; -import { Graph } from './graph.js'; -import { bundleHash, loadBundle, type Bundle, type BundleHash } from './bundle.js'; -import { literals, makeLiteral, outputType, view } from './codecs.js'; -import { runIO } from './io.js'; -import type { ClientMessage, WorkerMessage, RequestId } from './protocol.js'; -const scope = globalThis as unknown as { postMessage(message: WorkerMessage): void; onmessage: ((event: MessageEvent<unknown>) => void) | null }; -const send = (message: WorkerMessage): void => scope.postMessage(message); -const running = (): void => send({ type: 'state', state: 'running' }); -let phase: 'empty' | 'loading' | 'ready' | 'running' | 'done' = 'empty'; -let graph: Graph | undefined, exports: Map<string, import('./graph.js').NodeId> | undefined; -let stored: { hash: BundleHash; bundle: Bundle; bytes: Uint8Array } | undefined; -let requestId = 0; -let pending: { id: RequestId; kind: 'step' | 'input'; resolve(value: string): void; reject(error: Error): void } | undefined; -function request(kind: 'step' | 'input'): Promise<string> { - if (pending) throw new Error('Driver already waiting'); - const id = ++requestId as RequestId; - return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); -} -async function load(hash: BundleHash, localId: string | undefined): Promise<void> { - phase = 'loading'; running(); - const loaded = await publishedBundle(hash, localId); - const { bundle, source } = loaded; - stored = loaded; - const budget = BROWSER_BUDGET; - graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); exports = loadBundle(graph, bundle); - phase = 'ready'; - send({ type: 'loaded', hash, exports: bundle.exports, nodes: bundle.nodes.length, metadata: bundle.metadata, source }); -} -async function run(message: Extract<ClientMessage, { type: 'run' }>): Promise<void> { - if (phase !== 'ready' || !graph || !exports) throw new Error('Load a bundle before running'); - phase = 'running'; running(); - const fn = message.expression === undefined ? exports.get(message.export) : await compileExpression(graph, exports, message.expression); - if (fn === undefined) throw new Error('Unknown export'); - if (message.mode !== 'pure' && message.mode !== 'io') throw new Error('Unsupported execution mode'); - const type = outputType(message.output), args = literals(message.arguments).map(value => makeLiteral(graph!, value)); - let result = message.expression === undefined ? graph.apply(fn, args) : fn; - graph.collect([result]); // Exclusive one-run session: only this application remains owned. - if (message.mode === 'io') result = await runIO(graph, result, { - async before(event) { send({ type: 'trace', phase: 'before', event }); if (message.step === true) await request('step'); running(); }, - after(event) { send({ type: 'trace', phase: 'after', event }); }, - write(text) { send({ type: 'output', text }); }, read() { return request('input'); }, running, - }, true); - const displayed = view(graph, result, type); - phase = 'done'; send({ type: 'done', result: displayed, stats: graph.stats }); -} -scope.onmessage = ({ data }) => { - if (!data || typeof data !== 'object' || Array.isArray(data)) return; - const message = data as Record<string, unknown>; - if (message['type'] === 'resume') { - if (!pending || message['id'] !== pending.id) return; - const value = resumeValue(pending.kind, message['value']); - if (value === undefined) return; - const waiter = pending; pending = undefined; waiter.resolve(value); - return; - } - if (message['type'] === 'download' && phase === 'ready' && stored) { - send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); return; - } - if (message['type'] === 'export' && phase === 'ready' && stored) { - try { - if (typeof message['export'] !== 'string' || message['export'].length > 512) throw new Error('Invalid export selection'); - const format = treeFormat(message['format']); - const file = exportTree(stored.bundle, message['export'], format); - send({ type: 'exported', hash: stored.hash, export: message['export'], format, file }); - } catch (error) { send({ type: 'export-error', message: error instanceof Error ? error.message : String(error) }); } - return; - } - let operation: Promise<void>; - try { - if (message['type'] === 'load' && phase === 'empty') { - const localId = message['localId']; - if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) throw new Error('Invalid catalogue ID'); - operation = load(bundleHash(message['hash']), localId); - } else if (message['type'] === 'run' && phase === 'ready') { - if (typeof message['export'] !== 'string' || message['export'].length > 512) throw new Error('Invalid export selection'); - operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], - ...(message['expression'] === undefined ? {} : { expression: sourceText(message['expression']) }), - output: outputType(message['output']), mode: message['mode'] as 'pure' | 'io', step: message['step'] === true }); - } else return; // A Worker owns exactly one load and one run, no racing sessions. - } catch (error) { operation = Promise.reject(error); } - operation.catch((error: unknown) => { phase = 'done'; send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); }); -}; diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -7,7 +7,23 @@ .output-panel{border-top:1px solid var(--line);padding:20px;min-width:0}.output-toolbar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.output-toolbar>div{min-width:0}#output-provenance{margin-block:16px 8px}#result{min-height:64px;max-height:520px;overflow:auto;margin:0;padding:16px;background:#f3f4ee;border:1px solid var(--line);border-radius:4px}#result:empty::before{content:"Your output appears here.";color:var(--muted)}#result-stale{border-left:3px solid #b28132;padding-left:10px}.output-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}#io-console{margin-top:20px}#terminal{max-height:260px;overflow:auto;padding:12px;border-left:3px solid var(--green);background:#eef2e8}#terminal:empty{display:none}#trace{max-height:260px;overflow:auto;list-style:none;padding:0;font:12px/1.6 ui-monospace,monospace}#trace li{padding-block:8px;border-bottom:1px solid var(--line);overflow-wrap:anywhere;white-space:pre-wrap}#trace li.pending{color:var(--green)}.trace-panel{border-top:1px solid var(--line);padding-top:12px} .explanation{padding-block:64px}.explanation-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:36px;margin-top:28px}.explanation-grid h3{font-size:17px}.explanation-grid p{font-size:14px;line-height:1.8;color:var(--muted)}footer{padding-block:25px 35px;border-top:1px solid var(--line);font-size:12px;line-height:1.8;color:var(--muted)} @media(min-width:950px){.shapes{position:absolute;right:38px;top:70px;flex-direction:column;gap:15px}} -@media(max-width:1119px) and (min-width:741px){.workspace{margin-inline:28px}.toolbar-actions{flex-basis:100%}} +@media(max-width:1119px) and (min-width:741px){.workspace{margin-inline:28px}} @media(max-width:949px){.shapes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;max-width:570px}.shapes div{width:auto}.shapes svg{max-width:100%;margin-inline:auto}.hero>.button{display:block;width:fit-content;max-width:100%;margin-inline:auto;text-align:center}} -@media(max-width:740px){.wrap{padding-inline:18px}.hero{padding-block:38px}.wordmark span{display:none}header{flex-wrap:wrap;gap:10px;padding-block:20px}nav{gap:14px}nav a{display:inline-flex;align-items:center;min-height:44px}.workspace{width:100%;max-width:none;margin:0;border-left:0;border-right:0;border-radius:0;overflow:visible}.editor-body.settings-open{grid-template-columns:minmax(0,1fr)}.editor-toolbar{padding:14px 16px;gap:10px}.toolbar-selection{flex:1 1 calc(100% - 140px)}.toolbar-runtime{flex:0 0 120px}.toolbar-actions{flex-basis:100%;display:grid;grid-template-columns:1fr auto auto}.toolbar-actions button{min-width:0}.source-heading{padding-inline:16px}#authorship{padding-inline:16px}#source-editor{font-size:16px;padding:16px;width:100%;height:360px;min-height:240px;max-height:none}.source-actions,.expression-dock,.output-panel{padding-inline:16px}.output-toolbar{grid-template-columns:repeat(2,minmax(0,1fr))}.output-toolbar select{font-size:16px}input,textarea,select{font-size:16px}#settings-panel{position:fixed;inset:12vh 0 0;z-index:20;border:0;border-top:1px solid var(--line);border-radius:16px 16px 0 0;overflow:auto;padding:18px 18px max(24px,env(safe-area-inset-bottom));box-shadow:0 -8px 40px #0003}#settings-backdrop{position:fixed;inset:0;background:#14271e77;z-index:19}.inspector-modal{overflow:hidden}.inspector-heading{position:sticky;top:-18px;background:var(--paper);padding-block:12px;z-index:1}.explanation-grid{grid-template-columns:1fr;gap:16px}.explanation{padding-block:45px}#argument-fields{grid-template-columns:minmax(0,1fr)}} +@media(max-width:740px){.wrap{padding-inline:18px}.hero{padding-block:38px}.wordmark span{display:none}header{flex-wrap:wrap;gap:10px;padding-block:20px}nav{gap:14px}nav a{display:inline-flex;align-items:center;min-height:44px}.workspace{width:100%;max-width:none;margin:0;border-left:0;border-right:0;border-radius:0;overflow:visible}.editor-body.settings-open{grid-template-columns:minmax(0,1fr)}.editor-toolbar{padding:14px 16px;gap:10px}.toolbar-selection{flex:1 1 calc(100% - 140px)}.toolbar-runtime{flex:0 0 120px}.toolbar-actions{flex-basis:auto;order:4;display:flex;margin-left:auto}.toolbar-actions button{min-width:0}.source-heading{padding-inline:16px}#authorship{padding-inline:16px}#source-editor{font-size:16px;padding:16px;width:100%;height:360px;min-height:240px;max-height:none}.source-actions,.expression-dock,.output-panel{padding-inline:16px}.output-toolbar{grid-template-columns:repeat(2,minmax(0,1fr))}.output-toolbar select{font-size:16px}input,textarea,select{font-size:16px}#settings-panel{position:fixed;inset:12vh 0 0;z-index:20;border:0;border-top:1px solid var(--line);border-radius:16px 16px 0 0;overflow:auto;padding:18px 18px max(24px,env(safe-area-inset-bottom));box-shadow:0 -8px 40px #0003}#settings-backdrop{position:fixed;inset:0;background:#14271e77;z-index:19}.inspector-modal{overflow:hidden}.inspector-heading{position:sticky;top:-18px;background:var(--paper);padding-block:12px;z-index:1}.explanation-grid{grid-template-columns:1fr;gap:16px}.explanation{padding-block:45px}#argument-fields{grid-template-columns:minmax(0,1fr)}} @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} +/* Native textarea editing, decorative syntax mirror. No contenteditable/HTML input. */ +.syntax-editor{position:relative;min-width:0;background:var(--panel);--syntax-ink:var(--ink);--syn-comment:#56695e;--syn-string:#27613d;--syn-escape:#95511e;--syn-number:#27578c;--syn-keyword:#6c367c;--syn-definition:#754300;--syn-tree:#006b60;--syn-contract:#934824;--syn-invalid:#ae3026;--syn-match:#d4e5c4} +.source-panel{--syntax-ink:#e3edde;--syn-comment:#9cafa4;--syn-string:#c0d9a3;--syn-escape:#f4bc8d;--syn-number:#9ecff5;--syn-keyword:#d6b5ef;--syn-definition:#ffd08a;--syn-tree:#73dbc0;--syn-contract:#f4bc8d;--syn-invalid:#ffb2a6;--syn-match:#435b43} +.source-panel .syntax-editor{background:var(--code);--syntax-ink:inherit;--syn-comment:inherit;--syn-string:inherit;--syn-escape:inherit;--syn-number:inherit;--syn-keyword:inherit;--syn-definition:inherit;--syn-tree:inherit;--syn-contract:inherit;--syn-invalid:inherit;--syn-match:inherit} +.source-panel .syntax-editor.modified{background:#273c32}.syntax-editor.disabled .syntax-viewport{opacity:.75} +.syntax-viewport{position:absolute;pointer-events:none;overflow:hidden;user-select:none} +.syntax-viewport pre{margin:0;border:0;border-radius:0;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;min-height:100%;color:var(--syntax-ink);transform-origin:top left} +#source-editor.syntax-input,#expression-editor.syntax-input{position:relative;color:transparent;-webkit-text-fill-color:transparent;caret-color:var(--syntax-ink);background:transparent;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;display:block} +.syntax-input::selection{background:#799ea066;color:transparent}.syntax-comment{color:var(--syn-comment)}.syntax-string{color:var(--syn-string)}.syntax-escape{color:var(--syn-escape)}.syntax-number{color:var(--syn-number)}.syntax-keyword,.syntax-directive{color:var(--syn-keyword)}.syntax-tree{color:var(--syn-tree)}.syntax-definition{color:var(--syn-definition)}.syntax-contract{color:var(--syn-contract)}.syntax-invalid{color:var(--syn-invalid);text-decoration:underline wavy}.bracket-match{background:var(--syn-match);box-shadow:inset 0 -2px var(--syn-definition)}.bracket-unmatched{text-decoration:underline wavy var(--syn-invalid);text-underline-offset:3px} +.syntax-editor.composing .syntax-viewport{visibility:hidden}.syntax-editor.composing .syntax-input{color:var(--syntax-ink)!important;-webkit-text-fill-color:var(--syntax-ink)!important} +@media(forced-colors:active){.syntax-viewport{display:none}#source-editor.syntax-input,#expression-editor.syntax-input{color:CanvasText;-webkit-text-fill-color:CanvasText;caret-color:CanvasText}.syntax-input::selection{background:Highlight;color:HighlightText;-webkit-text-fill-color:HighlightText}} +.syntax-editor{--syn-operator:#516263}.source-panel{--syn-operator:#aac6cc}.source-panel .syntax-editor{--syn-operator:inherit}.syntax-operator,.syntax-bracket{color:var(--syn-operator)}.syntax-input,.syntax-viewport pre{font-variant-ligatures:none} + +/* Keep the expression, explicit action, and result in one reading column. */ +.expression-dock .output-toolbar{margin-bottom:18px}.evaluation-actions{display:flex;gap:8px;align-items:center;margin-top:14px}.output-panel #output-provenance{margin-top:0} +@media(max-width:740px){.editor-toolbar #status{flex:1 1 100px;order:3;align-self:center}.evaluation-actions #run{flex:1}.evaluation-actions button{min-height:48px}} diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -59,11 +59,21 @@ try { for(const width of [320,390,1440]){ await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950}); assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Expression workspace fits '+width); + if(process.env.TEST_IDE){await evaluate("import('/syntax-probe.mjs').then(m=>m.syntaxProbe())");await evaluate("import('/evaluation-layout-probe.mjs').then(m=>m.evaluationLayout())");} if(width<741)assert.equal(await evaluate("(()=>{const r=document.getElementById('source-editor').getBoundingClientRect();return r.left===0&&r.width===innerWidth;})()"),true,'Mobile editor reaches both viewport edges'); assert.equal(await evaluate("parseFloat(getComputedStyle(document.getElementById('expression-editor')).fontSize)>=16"),true,'Legible expression text'); } } if(process.env.TEST_IDE){ + await send('Emulation.setEmulatedMedia',{features:[{name:'forced-colors',value:'active'}]}); + assert.equal(await evaluate("getComputedStyle(document.querySelector('.syntax-viewport')).display==='none'&&getComputedStyle(document.getElementById('expression-editor')).webkitTextFillColor!=='rgba(0, 0, 0, 0)'"),true,'High contrast retains readable native text'); + await send('Emulation.setEmulatedMedia',{features:[]}); + const beforeEdit=await evaluate("(()=>{const e=document.getElementById('expression-editor');e.focus();e.setSelectionRange(e.value.length,e.value.length);return e.value;})()"); + await send('Input.insertText',{text:' -- native undo'}); + assert.equal(await evaluate("document.getElementById('expression-editor').value"),beforeEdit+' -- native undo'); + await send('Input.dispatchKeyEvent',{type:'keyDown',key:'z',code:'KeyZ',modifiers:2,windowsVirtualKeyCode:90,nativeVirtualKeyCode:90}); + await send('Input.dispatchKeyEvent',{type:'keyUp',key:'z',code:'KeyZ',modifiers:2,windowsVirtualKeyCode:90,nativeVirtualKeyCode:90}); + assert.equal(await evaluate("document.getElementById('expression-editor').value"),beforeEdit,'Highlighting preserves native undo'); await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); await evaluate("document.getElementById('settings-toggle').click()"); assert.equal(await evaluate("document.getElementById('settings-panel').getAttribute('aria-modal')==='true'&&document.querySelector('.editor-main').inert"),true,'Mobile settings is modal'); @@ -75,6 +85,7 @@ try { assert.equal(await evaluate("document.getElementById('settings-panel').hidden&&!document.querySelector('.editor-main').inert&&document.activeElement.id==='settings-toggle'"),true,'Escape releases modality and returns focus'); if(process.env.TEST_SCREENSHOT_PREFIX)for(const width of [390,1440]){ await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741}); + await evaluate("new Promise(r=>{window.scrollTo({top:0,behavior:'instant'});requestAnimationFrame(()=>requestAnimationFrame(r));})"); const {cssContentSize:r}=await send('Page.getLayoutMetrics'); const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:r.width,height:r.height,scale:1}}); await writeFile(process.env.TEST_SCREENSHOT_PREFIX+'-'+width+'.png',Buffer.from(shot.data,'base64')); diff --git a/ext/demo-site/test/backend-firefox.mjs b/ext/demo-site/test/backend-firefox.mjs @@ -9,9 +9,14 @@ const {server,url,completion,marks}=await startSiteServer(process.argv[2],true); const profile=await mkdtemp(resolve(tmpdir(),'tricu-site-firefox-')); let browser,timer,stopMemory; try { - browser=spawn(process.argv[3]||'firefox',['--headless','--no-remote','--new-instance','--profile',profile,url],{stdio:['ignore','ignore','pipe'],detached:true}); + // --profile does not isolate Firefox's global Crash Reports directory. + // Keep all test state under the temporary root and disable external crash + // reporting, not browser failure detection (exit/stderr/probe timeout below). + const env={...process.env,HOME:profile,XDG_CONFIG_HOME:resolve(profile,'config'), + XDG_CACHE_HOME:resolve(profile,'cache'),XDG_DATA_HOME:resolve(profile,'data'),MOZ_CRASHREPORTER_DISABLE:'1',GSETTINGS_BACKEND:'memory'}; + browser=spawn(process.argv[3]||'firefox',['--headless','--no-remote','--new-instance','--profile',profile,url],{stdio:['ignore','ignore','pipe'],detached:true,env}); if(process.env.TEST_BENCHMARK)stopMemory=sampleBrowser(browser.pid,marks); - let log='';browser.stderr.on('data',b=>{log=(log+b).slice(-16000);}); + let log='';browser.stderr.on('data',b=>{log=(log+b).slice(-16000);process.stderr.write(b);}); const failure=new Promise((_,reject)=>{ browser.on('error',reject);browser.on('exit',code=>reject(Error('Firefox exited '+code+'\n'+log))); timer=setTimeout(()=>reject(Error('Site backend test timed out\n'+log)),180000); diff --git a/ext/demo-site/test/backend-probe.mjs b/ext/demo-site/test/backend-probe.mjs @@ -92,11 +92,11 @@ export async function run(control, selectedBackend='haskell-wasm') { await click('stop');await wait(status('Stopped'));assert.equal(await evaluate("document.getElementById('run').disabled"),true); // Backend switching preserves text but never carries heaps/checkpoints across. const source=await evaluate("document.getElementById('source-editor').value"); - await evaluate("document.getElementById('runtime-backend').value='js';document.getElementById('runtime-backend').dispatchEvent(new Event('change'))"); + await evaluate(`document.getElementById('runtime-backend').value=${JSON.stringify(selectedBackend==='haskell-wasm'?'zig-wasm':'haskell-wasm')};document.getElementById('runtime-backend').dispatchEvent(new Event('change'))`); assert.equal(await evaluate("document.getElementById('run').disabled"),true); assert.equal(await evaluate("document.getElementById('source-editor').value"),source); await click('compile');await wait(status('Compiled')); - assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/work units/); + assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/MiB WASM linear memory/); await(await import('/backend-resume-probe.mjs')).probeResume(selectedBackend); const parity=selectedBackend==='zig-wasm'?await(await import('/zig-session-probe.mjs')).run():null; return {parity,userAgent:navigator.userAgent,checks:['Haskell default startup/execution','package/IO/export','default/empty/practical compilation','persistent definitions','recoverable errors','artifact/pin failure without fallback','hard Stop/recompile including CPU-bound work','IO stepping/stale and malformed resume rejection','backend-switch isolation']}; diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -28,6 +28,8 @@ export async function startSiteServer(directory,autoRun=false) { if(path==='/zig-session-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./zig-session-probe.mjs',import.meta.url)));return;} if(path==='/backend-resume-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-resume-probe.mjs',import.meta.url)));return;} if(path==='/backend-benchmark.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-benchmark.mjs',import.meta.url)));return;} + if(path==='/evaluation-layout-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./evaluation-layout-probe.mjs',import.meta.url)));return;} + if(path==='/syntax-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./syntax-probe.mjs',import.meta.url)));return;} if(path==='/ide-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./ide-probe.mjs',import.meta.url)));return;} if(path==='/live-ux-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./live-ux-probe.mjs',import.meta.url)));return;} if(path==='/backend-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-probe.mjs',import.meta.url)));return;} diff --git a/ext/demo-site/test/compiler-package.test.mjs b/ext/demo-site/test/compiler-package.test.mjs @@ -16,7 +16,7 @@ async function session(message,fetcher,after){ timer=setTimeout(()=>reject(Error('worker test timeout')),5000); globalThis.postMessage=data=>{messages.push(data);if(data.type==='loaded'||data.type==='error')resolve(data);}; }); - await import('../dist/worker.js?test='+ ++instance); + await import('../dist/conformance/worker.js?test='+ ++instance); globalThis.onmessage({data:message}); const result=await reply; if(after)await after(result,messages); diff --git a/ext/demo-site/test/conformance-worker.test.mjs b/ext/demo-site/test/conformance-worker.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +// A cheap actual compiler smoke keeps relocated test-only drivers executable. +// No program runs in the site's production JS (there is no such backend). +test('test-only JS driver compiles, checkpoints, restores and evaluates an expression',async()=>{ + const saved={fetch:globalThis.fetch,onmessage:globalThis.onmessage,postMessage:globalThis.postMessage}; + let timer; + try{ + globalThis.fetch=async input=>{ + const url=new URL(input),hash=url.pathname.match(/\/_arboricx\/bundle\/hash\/([0-9a-f]{64})$/)?.[1]; + const file=hash?new URL('../bundles/'+hash+'.arboricx',import.meta.url):url; + return new Response(await readFile(file)); + }; + const send=(message,type)=>new Promise((resolve,reject)=>{ + timer=setTimeout(()=>reject(Error('Conformance worker timeout')),30000); + globalThis.postMessage=reply=>{ + if(reply.type==='error'){clearTimeout(timer);reject(Error(reply.message));} + if(reply.type===type){clearTimeout(timer);resolve(reply);} + }; + globalThis.onmessage({data:message}); + }); + await import('../dist/conformance/compiler-worker.js?compile'); + const compiled=await send({type:'compile',environment:'empty',source:'id = (x : x)'},'compiled'); + assert.deepEqual(compiled.names,['id']);assert.ok(compiled.draft); + await import('../dist/conformance/compiler-worker.js?restore'); + globalThis.onmessage({data:{type:'restore',draft:structuredClone(compiled.draft)}}); + const done=await send({type:'run',export:'id',expression:'id 42',arguments:[],output:'number',mode:'pure',step:false},'done'); + assert.equal(done.result.value,'42'); + }finally{ + clearTimeout(timer); + for(const [key,value] of Object.entries(saved))if(value===undefined)delete globalThis[key];else globalThis[key]=value; + } +}); diff --git a/ext/demo-site/test/evaluation-layout-probe.mjs b/ext/demo-site/test/evaluation-layout-probe.mjs @@ -0,0 +1,43 @@ +// Uses the actual selected runtime and page actions; no mocked evaluator. +const el=id=>document.getElementById(id),assert=(ok,message)=>{if(!ok)throw Error(message);}; +const frame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; +const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; +async function wait(status){const end=Date.now()+45000;while(el('status').textContent!==status){if(Date.now()>end)throw Error('Waiting for '+status+': '+el('status').textContent);await delay(20);}await frame();} +export async function evaluationLayout(){ + const dock=document.querySelector('.expression-dock'),panel=document.querySelector('.output-panel'); + assert(dock.contains(el('run'))&&dock.contains(el('stop-run')),'Actions belong to expression dock'); + assert(dock.nextElementSibling===panel&&panel.parentElement.classList.contains('editor-main'),'Result immediately follows expression in the same column'); + assert(!document.querySelector('.editor-toolbar').contains(el('run')),'Evaluate is not a distant sticky action'); + change('output-target','result');change('mode','pure');change('output-type','number');input('expression-editor','identity 42'); + const mobile=matchMedia('(max-width:740px)').matches; + // Start with a result well below the viewport. No user scroll event is emitted. + window.scrollTo({top:0,behavior:'instant'});await frame(); + const before=scrollY;el('run').click(); + assert(!el('stop').hidden&&!el('stop-run').disabled,'Both Stop controls available while running'); + await wait('Finished'); + assert(el('result').textContent==='42','Actual result appears'); + if(mobile){const r=el('result').getBoundingClientRect(),bar=document.querySelector('.editor-toolbar').getBoundingClientRect();assert(r.top>=bar.bottom&&r.top+40<=innerHeight,'Offscreen result start is revealed below sticky toolbar');} + else assert(scrollY===before,'Desktop does not auto-scroll'); + assert(el('stop').hidden,'Sticky Stop disappears when idle'); + // When the result is already visible, repeating an evaluation must stay put. + if(!mobile){el('run').scrollIntoView({block:'center',behavior:'instant'});await frame();} + const visibleBefore=scrollY;el('run').click();await wait('Finished');assert(Math.abs(scrollY-visibleBefore)<=1,'Visible result does not jump'); + window.scrollTo({top:0,behavior:'instant'});await frame();el('run').click();window.dispatchEvent(new Event('wheel'));await wait('Finished');assert(scrollY===0,'Deliberate browsing cancels pending reveal'); + // Stored definitions share the nearby action without displaying irrelevant input. + change('output-target','definition');change('export','leaf');change('output-type','tricu'); + assert(el('expression-inputs').hidden&&el('guided-inputs').hidden&&el('run').textContent==='Show definition','Stored target hides expression input'); + el('run').click();await wait('Ready');assert(el('result').textContent==='t','Stored definition action still works'); + change('output-target','result');assert(!el('expression-inputs').hidden,'Expression returns for evaluation'); + input('expression-editor','unknownNameForLayout');el('run').click();await wait('Stopped'); + assert(el('result').classList.contains('error'),'Explicit evaluation diagnostics use the same result surface'); + // First IO event reveals once; later input/output/completion never follows it. + change('mode','io');change('output-type','tricu');input('expression-editor','t "tricuIO" (t 1 (t 11 (line : t "tricuIO" (t 1 (t 0 t)))))'); + el('run').click();await wait('Waiting for input'); + assert(document.activeElement!==el('line-input'),'IO does not force focus or open the keyboard'); + window.scrollTo({top:0,behavior:'instant'});await frame();input('line-input','hello');el('input-form').requestSubmit();await wait('Finished');assert(scrollY===0,'IO completion does not scroll again'); + // Idle compilation has no reveal authority. + change('mode','pure');window.scrollTo({top:0,behavior:'instant'});await frame();input('source-editor','answer = t');await wait('Compiled');assert(scrollY===0,'Idle compilation leaves the viewport alone'); + change('program-picker','demo:intro');await wait('Ready'); +} diff --git a/ext/demo-site/test/ide-probe.mjs b/ext/demo-site/test/ide-probe.mjs @@ -1,3 +1,4 @@ +import {syntaxProbe} from './syntax-probe.mjs'; // Real single-page workspace. Independent expectations, no substitute evaluator. const el=id=>document.getElementById(id); const assert=(ok,message)=>{if(!ok)throw Error(message);}; @@ -8,6 +9,9 @@ const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('cha const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; export async function run(control,backend='haskell-wasm'){ await status('Ready'); + await syntaxProbe(); + assert([...el('runtime-backend').options].map(o=>o.value).join(',')==='haskell-wasm,zig-wasm','Only Haskell and Zig are production runtimes'); + for(const path of ['worker.js','compiler-worker.js','conformance/worker.js','conformance/compiler-worker.js'])assert((await fetch(new URL('dist/'+path,location.href))).status===404,'JS conformance Workers are not shipped: '+path); assert(el('program-picker').value==='demo:intro','Start Here loads on arrival'); assert(!document.querySelector('[role=tab],#graph-result,#export-definition'),'No tabs or graph-JSON surface'); assert(!el('result').textContent&&!el('terminal').textContent,'Arrival does not execute'); @@ -70,5 +74,6 @@ export async function run(control,backend='haskell-wasm'){ change('program-picker','demo:intro');await status('Ready'); const live=await(await import('/live-ux-probe.mjs')).run(control,backend,true); change('program-picker','demo:intro');await status('Ready'); + await(await import('/evaluation-layout-probe.mjs')).evaluationLayout(); return {userAgent:navigator.userAgent,checks:[...live.checks,'single-page arrival/inspector; stored vs applied outputs; t/△/DAG/ternary; IO result; complete copy/download; expansion refusal; immutable bundle; pin failure/retry']}; } diff --git a/ext/demo-site/test/live-ux-probe.mjs b/ext/demo-site/test/live-ux-probe.mjs @@ -1,4 +1,4 @@ -// Actual page, all three backends. No replacement evaluator or UI implementation. +// Actual page, both production backends. No replacement evaluator or UI implementation. const assert=(value,message)=>{if(!value)throw Error(message);}; const el=id=>document.getElementById(id); const delay=ms=>new Promise(r=>setTimeout(r,ms)); diff --git a/ext/demo-site/test/output-reveal.test.mjs b/ext/demo-site/test/output-reveal.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {revealDelta} from '../dist/output-reveal.js'; +test('only an offscreen result start needs revealing, not the whole large result',()=>{ + assert.equal(revealDelta(250,10000,140,0,844),0); + assert.equal(revealDelta(900,10000,140,0,844),748); + assert.equal(revealDelta(100,100,140,0,844),-52); + assert.equal(revealDelta(810,100,140,0,844),658); +}); +test('reveal respects visual viewport offsets and small keyboard-height viewports',()=>{ + assert.equal(revealDelta(300,100,100,120,360),0); + assert.equal(revealDelta(460,100,100,120,360),228); + assert.equal(revealDelta(400,100,140,0,100),0); +}); + +test('foreground reveal is once-only, cancelable, mobile-only and never steals focus',async()=>{ + const saved=Object.fromEntries(['window','matchMedia','requestAnimationFrame','innerHeight'].map(k=>[k,globalThis[k]])); + const frames=[],scrolls=[];let mobile=true,inert=false; + try{ + const win=new EventTarget();win.visualViewport={offsetTop:0,height:844};win.scrollBy=value=>scrolls.push(value); + globalThis.window=win;globalThis.innerHeight=844;globalThis.matchMedia=()=>({matches:mobile});globalThis.requestAnimationFrame=fn=>frames.push(fn); + const {outputReveal}=await import('../dist/output-reveal.js'); + const controller=outputReveal({getBoundingClientRect:()=>({height:140})}); + const target={getBoundingClientRect:()=>({top:900,height:2000}),getClientRects:()=>[{}],closest:()=>inert?{}:null,focus:()=>assert.fail('Must not focus the result')}; + const flush=()=>{while(frames.length)frames.shift()(0);}; + controller.reveal(target);flush();assert.equal(scrolls.length,0,'Background work never arms reveal'); + controller.arm();controller.reveal(target);controller.reveal(target);flush();assert.equal(scrolls.length,1,'Only first result/IO event reveals'); + assert.deepEqual(scrolls[0],{top:748,behavior:'instant'}); + controller.arm();controller.reveal(target);controller.cancel();flush();assert.equal(scrolls.length,1,'Stop invalidates queued reveal'); + for(const event of ['wheel','touchmove','pagehide']){controller.arm();win.dispatchEvent(new Event(event));controller.reveal(target);flush();assert.equal(scrolls.length,1,event);} + controller.arm();win.dispatchEvent(Object.assign(new Event('keydown'),{key:'PageDown'}));controller.reveal(target);flush();assert.equal(scrolls.length,1); + mobile=false;controller.arm();controller.reveal(target);flush();assert.equal(scrolls.length,1,'Desktop stays put'); + mobile=true;inert=true;controller.arm();controller.reveal(target);flush();assert.equal(scrolls.length,1,'Modal settings stays put'); + }finally{for(const [key,value]of Object.entries(saved))if(value===undefined)delete globalThis[key];else globalThis[key]=value;} +}); diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py @@ -33,11 +33,14 @@ def main(): assert not list(args.site.rglob('*.arboricx')) assert not (args.site / 'bundles').exists() assert all('path' not in entry for entry in [*catalogue, compiler]) - # Package authentication/transport is shared by both Worker drivers now. - for script in ['worker.js', 'wasm-worker.js']: + # Production uses only native-runtime Worker drivers; JS is test-only. + assert not list(args.site.rglob('conformance')) + for retired in ['worker.js', 'compiler-worker.js']: + assert not (args.site / 'dist' / retired).exists() + for script in ['wasm-worker.js']: source = (args.site / 'dist' / script).read_text() assert './published-bundle.js' in source and 'bundles/' not in source - for script in ['published-bundle.js', 'compiler-worker.js', 'haskell-session.js']: + for script in ['published-bundle.js', 'haskell-session.js']: source = (args.site / 'dist' / script).read_text() assert '/_arboricx/bundle/hash/' in source and 'bundles/' not in source for backend, binary, scripts in [ diff --git a/ext/demo-site/test/runtime-backends.test.mjs b/ext/demo-site/test/runtime-backends.test.mjs @@ -5,14 +5,11 @@ import {backend,backends,runtimeStats} from '../dist/runtime-backends.js'; import {createRuntimeSession} from '../dist/runtime-session.js'; test('runtime selection is explicit, fixed and fails without fallback',async()=>{ - assert.deepEqual(Object.keys(backends),['js','zig-wasm','haskell-wasm']); + assert.deepEqual(Object.keys(backends),['zig-wasm','haskell-wasm']); assert.equal(backend('haskell-wasm'),'haskell-wasm'); - assert.deepEqual(Object.values(backends).map(x=>x.label),['JavaScript','Zig','Haskell']); - for(const name of ['toString','constructor','unknown'])assert.throws(()=>backend(name),/Unknown/); + assert.deepEqual(Object.values(backends).map(x=>x.label),['Zig','Haskell']); + for(const name of ['js','toString','constructor','unknown'])assert.throws(()=>backend(name),/Unknown/); for(const name of [null,'js','unknown'])await assert.rejects(createRuntimeSession(name,false),/Unsupported/); - assert.equal(backends.js.persistentDefinitions,false); - assert.equal(backends['haskell-wasm'].persistentDefinitions,true); - assert.equal(backends['zig-wasm'].persistentDefinitions,true); }); test('runtime diagnostics do not invent graph counters for WASM',()=>{ const value=runtimeStats({backend:'haskell-wasm',linearMemoryBytes:12*1048576}); @@ -25,8 +22,15 @@ test('the real page explicitly defaults to Haskell and retains alternative runti const selector=html.match(/<select id="runtime-backend">([\s\S]*?)<\/select>/)[1]; assert.match(selector,/^<option value="haskell-wasm" selected>Haskell<\/option>/); assert.match(selector,/>Zig<\/option>/); - assert.match(selector,/>JavaScript<\/option>/); + assert.doesNotMatch(selector,/JavaScript|value="js"/); assert.doesNotMatch(selector.replace(/<[^>]*>/g,''),/WASM|reference/i); assert.equal((selector.match(/\bselected\b/g)||[]).length,1); - for(const name of ['js','zig-wasm'])assert.match(selector,new RegExp('value="'+name+'"')); + for(const name of ['zig-wasm'])assert.match(selector,new RegExp('value="'+name+'"')); +}); + +test('production editor has no checkpoint restore or JS Worker branch',async()=>{ + const app=await readFile(new URL('../src/app.ts',import.meta.url),'utf8'); + assert.doesNotMatch(app,/CompiledDraft|compiledDraft|persistentDefinitions|type: 'restore'|compiler-worker\.js/); + const protocol=await readFile(new URL('../src/compiler-protocol.ts',import.meta.url),'utf8'); + assert.doesNotMatch(protocol,/GraphCheckpoint|CompiledDraft|restore/); }); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -12,6 +12,8 @@ test('single flowing editor starts before ecosystem, without tabs or graph JSON const choices=html.match(/<select id="output-type">([\s\S]*?)<\/select>/)[1]; for(const value of ['tricu','triangle','dag','string','number','record'])assert.ok(choices.includes(`value="${value}"`)); assert.doesNotMatch(choices,/value="tree"|value="list"/); + assert.match(choices,/<option value="triangle">△ source<\/option>/); + assert.doesNotMatch(choices,/Lambada/i); assert.match(html,/id="output-target"/);assert.match(html,/Stored definition/); const app=await read('src/app.ts');assert.match(app,/void initializePackages\(\)/);assert.doesNotMatch(app,/pane-change|activePane/); await assert.rejects(access(new URL('dist/tabs.js',here)));await assert.rejects(access(new URL('dist/export-app.js',here))); @@ -41,12 +43,22 @@ test('ecosystem remains with a copyright-only footer',async()=>{ assert.doesNotMatch(html,/Arboricx and tricu are research experiments|was discovered by|Johannes Bader/); for(const match of html.matchAll(/href="https:\/\/git\.eversole\.co\/James\/tricu\/file\/([^"#]+)\.html(?:#[^"]*)?"/g))await access(new URL('../../../'+match[1],import.meta.url)); }); -test('production workers retain unbounded execution and bounded loading/export transport',async()=>{ +test('conformance workers retain unbounded execution and shared transport limits',async()=>{ const {BROWSER_BUDGET,PACKAGE_LOAD_MS,EXPORT_ACTIVE_MS}=await import('../dist/execution.js'); assert.deepEqual(BROWSER_BUDGET,{fuel:Infinity,nodes:Infinity,stack:Infinity});assert.equal(PACKAGE_LOAD_MS,60000);assert.equal(EXPORT_ACTIVE_MS,120000); - for(const file of ['src/worker.ts','src/compiler-worker.ts']){ + for(const file of ['src/conformance/worker.ts','src/conformance/compiler-worker.ts']){ const source=await read(file);assert.match(source,/const budget = BROWSER_BUDGET/);assert.doesNotMatch(source,/executionSettings|executionBudget|\['limits'\]|\['uncapped'\]/); } for(const file of ['src/protocol.ts','src/compiler-protocol.ts','src/app.ts'])assert.doesNotMatch(await read(file),/ExecutionLimits|readSettings|limits-change|activeMilliseconds/); await assert.rejects(access(new URL('dist/limits-ui.js',here))); }); + +test('evaluation actions follow their expression and precede output, outside the sticky toolbar',async()=>{ + const html=await read('index.html'); + const toolbar=html.slice(html.indexOf('class="editor-toolbar"'),html.indexOf('id="editor-body"')); + assert.doesNotMatch(toolbar,/id="run"/);assert.match(toolbar,/id="stop" disabled hidden/); + assert.ok(html.indexOf('id="expression-editor"')<html.indexOf('id="run"')); + assert.ok(html.indexOf('id="run"')<html.indexOf('class="output-panel"')); + assert.ok(html.indexOf('class="output-panel"')<html.indexOf('id="settings-panel"')); + assert.match(html,/id="stop-run"/); +}); diff --git a/ext/demo-site/test/syntax-probe.mjs b/ext/demo-site/test/syntax-probe.mjs @@ -0,0 +1,33 @@ +// Browser-only presentation checks: no evaluator/compiler substitution. +const assert=(ok,message)=>{if(!ok)throw Error(message);}; +export async function syntaxProbe(){ + const source=document.getElementById('source-editor'), input=document.getElementById('expression-editor'); + const wrapper=input.closest('.syntax-editor'), mirror=wrapper.querySelector('pre'); + assert(source.closest('.syntax-editor').querySelector('.syntax-definition'),'Loaded source declarations are highlighted'); + const sourceMirror=source.closest('.syntax-editor').querySelector('pre'); + await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))); + assert(Math.abs(sourceMirror.scrollHeight-source.scrollHeight)<=2,'Source mirror wrapping matches native editor at this viewport'); + assert(document.querySelector('#source .syntax-definition'),'Read-only source gets the same tokens'); + assert(wrapper.querySelector('[aria-hidden=true]')&&input.tagName==='TEXTAREA','Decorative mirror leaves native accessible textarea intact'); + const saved=input.value; + const set=value=>{input.value=value;input.dispatchEvent(new Event('input'));}; + const caret=position=>{input.focus();input.setSelectionRange(position,position);input.dispatchEvent(new Event('select'));}; + set('let named x = [x (t)] in named "λ🙂["'); + assert(mirror.querySelector('.syntax-definition').textContent==='named','Local declaration color'); + assert(mirror.textContent===input.value,'Mirror preserves all source bytes'); + assert(getComputedStyle(mirror.querySelector('.syntax-definition')).color!==getComputedStyle(mirror.querySelector('.syntax-name')).color,'Definition names have a dedicated color'); + caret(input.value.indexOf('['));assert(mirror.querySelectorAll('.bracket-match').length===2,'Caret highlights bracket pair'); + caret(input.value.lastIndexOf('['));assert(!mirror.querySelector('.bracket-match'),'String brackets are ignored'); + input.setSelectionRange(0,4);input.dispatchEvent(new Event('select'));assert(!mirror.querySelector('.bracket-match'),'Range selections have no caret match'); + set('(t');caret(0);assert(mirror.querySelector('.bracket-unmatched'),'Unclosed delimiter marked without compiler interaction'); + set('<img src=x onerror="throw 1">');assert(!mirror.querySelector('img')&&mirror.textContent===input.value,'Source cannot become HTML'); + input.dispatchEvent(new CompositionEvent('compositionstart')); + assert(wrapper.classList.contains('composing')&&getComputedStyle(wrapper.querySelector('.syntax-viewport')).visibility==='hidden','IME uses visible native text, not a duplicate mirror'); + set('"λ🙂"');input.dispatchEvent(new CompositionEvent('compositionend'));assert(!wrapper.classList.contains('composing'),'IME restores highlighting'); + set(Array(80).fill('"λ🙂" t (t t) '+ 'longIdentifier '.repeat(12)).join('\n')+'\n'); + await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))); + assert(Math.abs(mirror.scrollHeight-input.scrollHeight)<=2,'Wrapping and trailing-line heights match native textarea'); + input.scrollTop=300;input.dispatchEvent(new Event('scroll')); + assert(mirror.style.transform.includes('-300px'),'Scrolling keeps decoration aligned'); + input.blur();set(saved);input.scrollTop=0;input.dispatchEvent(new Event('scroll')); +} diff --git a/ext/demo-site/test/syntax.test.mjs b/ext/demo-site/test/syntax.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {tokenize,bracketPair} from '../dist/syntax.js'; +const texts=(source,kind)=>tokenize(source).filter(t=>t.kind===kind).map(t=>t.text); +test('syntax is lossless and follows current lexical boundaries',()=>{ + const source='foo--bar x = "λ🙂\\n\\\"[(" -- ] ignored\n|- outer |- nonnested -| t\nx@nat? =@bool? 007 |> f <| g\n!import base as B\n!export main\n'; + assert.equal(tokenize(source).map(t=>t.text).join(''),source); + assert.deepEqual(texts(source,'definition'),['foo--bar','x']); + assert.deepEqual(texts(source,'contract'),['nat?','bool?']); + assert.deepEqual(texts(source,'number'),['007']); + assert.deepEqual(texts(source,'escape'),['\\n','\\"']); + assert.deepEqual(texts(source,'tree'),['t']); + assert.deepEqual(texts('do ioBind\n x <- f\n case x of\n _ = t','keyword'),['do','case','of']); + assert.deepEqual(texts('"\\q"','invalid'),['\\q']); +}); +test('definition sites include local equations but not parameters, uses, lambdas or case arms',()=>{ + const source='map [] = []\nmap [x | xs] = t x (map xs)\nmain = (let rec\n walk xs = case xs of\n value = value\n answer = walk []\n in answer)\nother = let id x = x in id t\nlast = helper t where helper x = x\nfn = (value : value)\n'; + assert.deepEqual(texts(source,'definition'),['map','map','main','walk','answer','other','id','last','helper','fn']); + assert.deepEqual(texts('!export thing\nthing = t','definition'),['thing']); + assert.deepEqual(texts('value <- f\n','definition'),[]); +}); +test('bracket matching uses UTF-16 caret offsets and skips strings/comments',()=>{ + const source='"🙂[" -- (\n([t {x}])';const tokens=tokenize(source); + const start=source.indexOf('(['); + assert.deepEqual(bracketPair(tokens,start),[start,source.length-1]); + assert.deepEqual(bracketPair(tokens,source.length),[source.length-1,start]); + assert.deepEqual(bracketPair(tokens,source.indexOf('[')),[]); + assert.deepEqual(bracketPair(tokenize('([)]'),0),[0]); + assert.deepEqual(bracketPair(tokenize('(t'),0),[0]); + assert.deepEqual(bracketPair(tokenize('|- [ -| "("'),3),[]); +}); +test('unfinished source and hostile markup remain plain token data',()=>{ + for(const source of ['"unfinished\\','|- unterminated (','<script>alert(1)</script>','let name','t\n','"\\🙂"',''])assert.equal(tokenize(source).map(t=>t.text).join(''),source); + const source='name = ['+'t '.repeat(8000)+']'; + assert.equal(tokenize(source).map(t=>t.text).join(''),source); +}); + +test('large read-only source falls back to lossless plain text rather than building unbounded spans',()=>{ + const source='t '.repeat(20000);assert.deepEqual(tokenize(source),[{start:0,end:source.length,kind:'',text:source}]); + assert.deepEqual(texts('!Local !result','directive'),[]); +}); diff --git a/ext/haskell-wasm/browser.nix b/ext/haskell-wasm/browser.nix @@ -20,6 +20,8 @@ pkgs.runCommand "tricu-haskell-wasm-browser" { -optl-Wl,--export=tricu_response_size,--export=tricu_response_copy \ -o $out/reactor.wasm cp -r ${../demo-site/dist} $out/site + chmod -R u+w $out/site + rm -r $out/site/conformance cp ${./browser-bridge.mjs} $out/bridge.mjs cp ${./browser-worker.mjs} $out/worker.mjs cp ${./browser-client.mjs} $out/client.mjs diff --git a/ext/wasm/README.md b/ext/wasm/README.md @@ -144,7 +144,7 @@ authentication-before-initialization and no-fallback controls run in `backend-check.mjs`; Chromium exercises both registered implementations. This boundary currently covers the experiment, not the deployed app's full IO, -package environment or retained-draft protocol. The site has a JS/Haskell/Zig selector and coarse session adapter (see +package environment or retained-draft protocol. The site has a Haskell/Zig selector and coarse session adapter (see `ext/demo-site/src/runtime-session.ts`). `site-session.mjs` is Zig's private production adapter; the older JS/Zig experiment remains an isolated harness. No backend shares another runtime's memory, handles or checkpoints.