tricu

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

commit 3d31a8ef3f64f0f216f8af4ff7c92a74086372b0
parent b32ab7b9d770258f5fd1c716dc1e380745bda2a7
Author: James Eversole <james@eversole.co>
Date:   Sun, 18 Oct 2026 17:30:46 -0500

Build a single-page live editor with cross-runtime expression evaluation

Compile idle source revisions without implicit execution and support expressions against draft definitions or authenticated package exports across Haskell, Zig, and JavaScript.

Replace tabs with an inline workspace, responsive settings inspector, and unified, explicitly targeted output. Add complete bounded tree snapshots for result serialization, preserving sharing and refusing partial exports. Retain cancellation, authentication, IO safety, and stale-revision guards.

Update browser and unit coverage, runtime documentation, and the copyright footer.

Diffstat:
Mext/demo-site/README.md | 320++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mext/demo-site/dist/app.js | 349+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mext/demo-site/dist/codecs.js | 7++++++-
Mext/demo-site/dist/compiler-worker.js | 8+++++---
Dext/demo-site/dist/export-app.js | 102-------------------------------------------------------------------------------
Aext/demo-site/dist/expression.js | 38++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/graph.js | 16+++++++++++++++-
Mext/demo-site/dist/haskell-session.js | 43++++++++++++++++++++++++++++++++++++++-----
Aext/demo-site/dist/inspector.js | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/output-app.js | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dext/demo-site/dist/tabs.js | 38--------------------------------------
Aext/demo-site/dist/tree-result.js | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/wasm-worker.js | 7+++++--
Mext/demo-site/dist/worker.js | 7+++++--
Mext/demo-site/index.html | 176+++++++++++++++++++++++++++++++------------------------------------------------
Mext/demo-site/src/app.ts | 266++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mext/demo-site/src/codecs.ts | 11+++++++----
Mext/demo-site/src/compiler-protocol.ts | 2+-
Mext/demo-site/src/compiler-worker.ts | 8+++++---
Dext/demo-site/src/export-app.ts | 76----------------------------------------------------------------------------
Aext/demo-site/src/expression.ts | 34++++++++++++++++++++++++++++++++++
Mext/demo-site/src/graph.ts | 11++++++++++-
Mext/demo-site/src/haskell-session.ts | 37++++++++++++++++++++++++++++++++-----
Aext/demo-site/src/inspector.ts | 32++++++++++++++++++++++++++++++++
Aext/demo-site/src/output-app.ts | 39+++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/protocol.ts | 2+-
Mext/demo-site/src/runtime-session.ts | 1+
Dext/demo-site/src/tabs.ts | 31-------------------------------
Aext/demo-site/src/tree-result.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/wasm-worker.ts | 7+++++--
Mext/demo-site/src/worker.ts | 7+++++--
Mext/demo-site/style.css | 260++++---------------------------------------------------------------------------
Mext/demo-site/test/backend-browser.mjs | 27++++++++++++++++++++++++++-
Mext/demo-site/test/backend-probe.mjs | 36+++++++++++++++++++-----------------
Mext/demo-site/test/backend-server.mjs | 4+++-
Mext/demo-site/test/browser.mjs | 1160+------------------------------------------------------------------------------
Mext/demo-site/test/draft-session.test.mjs | 2+-
Aext/demo-site/test/expression.test.mjs | 16++++++++++++++++
Aext/demo-site/test/ide-probe.mjs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/live-ux-probe.mjs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/test/site.test.mjs | 217++++++++++++++++---------------------------------------------------------------
Aext/demo-site/test/tree-result.test.mjs | 33+++++++++++++++++++++++++++++++++
Mext/haskell-wasm/BrowserRuntime.hs | 4++++
Mext/haskell-wasm/README.md | 8++++++++
Mext/haskell-wasm/Reactor.hs | 26++++++++++++++++++++++++++
Mext/haskell-wasm/browser-worker.mjs | 20++++++++++++++++----
Mext/wasm/README.md | 16++++++++++++++++
Mext/wasm/runtime.mjs | 7+++++++
Mext/wasm/site-session.mjs | 29+++++++++++++++++++++++------
Mext/zig/src/c_abi.zig | 11+++++++++++
Mext/zig/src/preview.zig | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
51 files changed, 1671 insertions(+), 2366 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -4,12 +4,18 @@ Explore Tree Calculus, run portable programs, compile tricu source, and export stored definitions in the browser. The site lives in `ext/demo-site` and uses TypeScript compiled to browser ES modules. -The three panels share one page: - -- **Ecosystem** introduces the language and its libraries. -- **Playground** selects named/versioned packages, applies arguments to published - definitions, and compiles editable demos or scratch programs in one workspace. -- **Export Definition** writes a stored tree without running it. +The page flows from the hero into one editor workspace, followed by the ecosystem +articles. **Start Here** loads on arrival, including when no selection fragment is +present; loading verifies a package but never executes it or downloads a compiler +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 +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. ## Runtime selection @@ -17,12 +23,12 @@ The **Runtime** selector offers **Haskell** (default), **Zig**, and **JavaScript**. 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 -text, and requires compilation again for a draft. Published selections reload on +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 release packages the pinned Haskell reactor, loader, WASI shim and authenticated -compiler kit under `runtime/haskell/`. Package-only runs do not download the -compiler kit. Draft compilation checks the runtime's original compiler-bundle +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 identity against `compiler.json`, then resolves the selected empty, practical or default-contract environment. Missing assets or mismatched pins are errors. Zig packages a freestanding reactor and private adapter under `runtime/zig/`. @@ -32,7 +38,7 @@ prepared kit. Both use the same environment/profile pins and names. All 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 with async IO callbacks, +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 cross backend types. `wasm-worker.ts` wraps the selected session; `haskell-session.ts` maps the built Haskell client's protocol, including its @@ -120,7 +126,7 @@ The Published packages group also contains these `0.1.0` and `0.1.1` interfaces: `compiler.json`, not a rebuilt or substituted compiler. The supported browser entrypoint is `source_unit.compileStableModuleDefinitions`; the full retained library also exposes native tooling helpers. Indexing it does not change - Playground's compiler selection or start compilation. + editor's compiler selection or start compilation. - **tricu-source-builder**: the exact authenticated shipped `bootstrap/build-stable.arboricx`, exporting `sourceBuildGraph`. This stable application compiler resolves module graphs, imports and exports and produces @@ -225,7 +231,7 @@ build-only edge through IO or the compiler closure. The package selector reads `packages.json`, a versioned static index of exact name/version → native manifest + whole-bundle pins. A single **Program** selector -is shared by Playground and Export, with grouped **Examples**, **Published +controls both result evaluation and stored-definition output, with grouped **Examples**, **Published packages**, and a visible **Load by bundle hash…** option. Versions are listed newest-first and opening an example or package selects its newest indexed release, independently of publication order. Conventional `MAJOR.MINOR.PATCH` versions use @@ -237,91 +243,81 @@ Shared bundle-hash links remain exact pins. Demo names appear only under Example again under Published packages. Examples with multiple releases show the version picker and **Load package**; changing versions invalidates prior results, and older releases load their exact bundle without borrowing current editable source. -Single-release examples also show an active version selector. The Published packages group contains -libraries and other non-demo packages, which reveal the version picker -and **Load package**. Hash loading reveals **Bundle hash (SHA-256)** and **Load -bundle**, accepting arbitrary browser-compatible bundles from this site's registry. -**Bundle details** is compact, always-visible verification and named-release metadata, not a disclosure or loader. There is no redundant selection-status line. - -The three routes are mutually exclusive. Changing the program, package version, -or entered hash discards the previous executable/result/export; Run and Export -stay disabled until the new bundle is verified. Bundle verification details and source authorship distinguish -pending choices from loaded programs and local drafts. Pending choices persist -when switching panels without silently reloading an example. Selecting a -release or following a selection link never runs a program. Browsing arbitrary -packages does not imply editable source or browser module resolution. - -The workspace shares the hero’s width and outer margins, and uses a -stacked source-first layout: Program and bundle details, full-width -**Source and editing**, then **Try this program** with definition, inputs, Run -and results. Both source and Try start expanded and can be collapsed independently. -Interaction trace is a child of Try, not a separate top-level section. Input help/raw JSON, result interpretation, IO mode and -execution mode and stepping controls live under **Advanced**. Tree results have a separate inspection -disclosure instead of dominating the result area. Pure programs do not show an -empty IO terminal. Curated input labels describe the published argument order; -arbitrary bundles and local drafts retain generic positional labels. - -All selected programs open their source/details section by default; reading comes before interaction. -Editable demos use **Source and editing / Read and edit source**. Compiled-only -packages instead use **Package details / Compiled package (no editable source)** -and explain that running/exporting remains available but editable source is not -included. Reference source without a supported compiler environment is labelled -**Source (read-only) / Read-only source**, with editing and local compilation -explicitly unavailable. Supported source is directly editable without an Edit -button. Until the text changes, **Run published** executes the authenticated -bundle, not a recompilation. Changing text switches to **Local draft**. -The first run requires an explicit **Compile draft**; compilation does not run -its result. After success, **Run draft** always runs the last successful compiled -version, even after further source edits. A persistent note identifies unapplied -edits. A failed compilation invalidates that version: it never falls back to an -earlier draft or the published executable. Loading another program also clears -it. **Restore published source** reinstates the original bundle and editable -source. The editor and authorship label use the modified color while text differs -from the current runnable compilation (or has not been compiled). Successful -compilation or restoring the published source clears that color; subsequent -unapplied edits restore it. There is no combined compile-and-run button. - -Ordinary run errors (including result interpretation errors) retain usable -compiled definitions. The JS backend restores its checkpoint for each attempt; -Run can replace a running JS attempt. Both WASM backends keep definitions in a live -Worker, so Run is disabled while that Worker is busy. Hard Stop, Reset during -active work, a fatal runtime failure or leaving the pane during a WASM run -loses those definitions; the UI requires Compile again and keeps the source. -Reset while idle and unapplied source edits retain the live WASM definitions. -The footer also provides Stop and a link to the Run controls. - -**Compile draft** starts directly when clicked; there is no confirmation popup. -There is no slow-compilation warning. An elapsed indicator and Stop remain -available; source is retained after cancellation or failure. There are no -configurable execution limits. - -**Download .arboricx**, beside the primary **Load package** action in both -Playground and Export, returns the original, bounded, hash-verified bundle -bytes for the selected version or hash, not a re-encoding, applied result or local -draft. Download verifies the bundle independently; it does not require Load, -change the loaded executable, compile source or run a program. Narrow layouts -place the download button on its own full-width toolbar row. Textual exports likewise -contain a selected stored definition. These actions and published sharing are -disabled while authoring a draft; restore the published source first. Draft bundle -publication/export is not part of this profile. +Single-release examples also expose their version in **Settings → Published +package**, alongside verification details, the hash loader, sharing and original +bundle download. Changing program, version or hash invalidates the preceding +executable and output; evaluation stays disabled until verification succeeds. +Published packages can be source-free or have read-only source. Their exports +remain usable in expressions and as stored-definition output without rebuilding +or modifying the implementation. Editable demos display source directly, without +an Edit button. Until the source changes, expressions use the authenticated package's +exports. Changing source switches to **Local draft** and queues compilation after +650 ms of idle time. **Compile now** starts immediately; neither path executes +user programs. The editor remains available during compilation. Edits are +coalesced rather than interrupting a compilation on every keystroke; stale +successes and diagnostics are discarded, and only the latest revision becomes +runnable. IME composition defers compilation until committed. Stop or changing +packages cancels queued work; compilation has no execution deadline. + +Evaluation is disabled whenever source differs from the compiled revision, during +compilation, or after a failed compilation. There is no fallback to old definitions +or the published executable. Previous output remains visible, marked stale; +compilation diagnostics appear beside the source without replacing that output. +**Restore published source** reinstates the original bundle and editable source. +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. +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. +Changing backend rebuilds a draft automatically, without transferring heaps or +executing it. Scrolling to ecosystem content does not terminate a session. +An elapsed compilation indicator and Stop remain available; there are no +configurable execution limits or slow-compilation warnings. + +**Settings → Download .arboricx** returns the original bounded, hash-verified +bytes for the selected version/hash, not an applied result or local draft. It +verifies independently and leaves the visible result and its Copy/Download payload +unchanged. Download/sharing and stored-definition output are disabled for drafts; +restore published source first. Draft package publication is not supported. Drafts exist only in the open page. There is no autosaving, browser-storage access, saved-source download or recovery UI. Compilation failure, Stop and -resource-setting changes retain source in the current editor; a page reload +backend changes retain source in the current editor; a page reload starts fresh from its published selection (or selection link), without restoring, compiling or running a draft. Copy important source elsewhere before leaving. Copies stored by older site releases are ignored, not read or restored. ### Arguments, results, and IO -Choose a definition and enter ordered inputs in separate literal fields. Each -field is **one argument**: `42`, `"hello"`, `[1 2 3]`, `t`, or `(t t t)`. -Add/Remove controls set the argument count. No fields means no application; -`[]` in a field means one empty-list argument. Suggested result interpretation and pure/browser-IO mode can both be changed -under **Advanced**. -Export metadata does not declare arity/types; no signature inference occurs. - -The editor accepts a data-only subset of tricu syntax: nonnegative decimal +**Evaluate tricu expression** is the default input. Examples include +`identity (first 42 7)` in the intro demo and `append "hello " "world"` in the +published base library. Expressions may use literals, applications, lambdas and +local bindings. Their environment contains exactly the selected package's +exports or current draft definitions, not an implicit prelude or module resolver. +Read-only and source-free packages work identically: the compiler binds names to +existing trees without reconstructing source or normalizing all exports. + +Expressions compile only when **Evaluate** is pressed, then run in the selected +pure/IO mode. A fresh, collision-free temporary definition wraps one expression; +its name is never installed in the package or draft. Diagnostics map wrapper +coordinates back to expression lines/columns. The wrapped source shares the +16,384-scalar source bound, so layout/wrapper overhead counts toward that bound. +Expression compilation and observation do not mutate the public definition table; +stored exports/downloads always use the authenticated original bundle. + +**Settings → Use structured arguments** retains the data-only input interface. +Each field is one argument: `42`, `"hello"`, `[1 2 3]`, `t`, or `(t t t)`. +No fields means no application; `[]` is one empty-list argument. Switching back +to expressions renders the structured call as tricu source; it does not parse an +arbitrary expression into argument fields. Result interpretation and pure/browser +IO mode remain visible in the output toolbar. Export metadata does not declare arity/types; +no signature inference occurs. + +The structured argument editor accepts a data-only subset of tricu syntax: nonnegative decimal numbers, scalar strings with tricu escapes, whitespace-separated lists, and Leaf/Stem/Fork constructor expressions (group constructor children with parentheses). It does not evaluate names, lambdas, general applications, comments, @@ -330,7 +326,7 @@ written as `t` / `(t t)`; byte lists use ordinary natural list syntax. Suggested bytes/Boolean inputs retain their original wire tags until edited, while edited literals produce the same canonical trees. -**Advanced → Input help and raw JSON** exposes the alternative typed JSON array editor for +**Settings → Structured argument encoding** exposes the alternative typed JSON array editor for exact wire kinds and bounded ternary function trees, including trees too deep for the literal editor. Raw kinds remain string, natural decimal string, Boolean, bytes, recursively typed list, and ternary tree. Switching editors preserves @@ -339,13 +335,21 @@ Partial and multiple applications are ordinary left-folded Tree Calculus applica The authenticated packaged intro guide still describes the raw JSON argument box; its published source and bundle identities have not been rewritten by this UI change. -Result codecs: Unicode string, natural number (decimal), Boolean, bytes, -bounded list of graph previews, or a bounded graph view. `t` can be zero, -false, or an empty string/list; decoding is selected explicitly. A graph result -need not be fully normalized; previewing descendants can demand more computation. The -JSON graph view contains root/constructor IDs, child handles, and forwarding -aliases, and marks truncated views. It is a physical graph view, not an unfolded -rendering. ID numbers are local to one Worker, not content identities. +Value interpretations include text, natural number, Boolean, bytes and scalar +record. `t` can represent zero, false or an empty string/list; interpretation is +explicit, not inferred. Former tree/list graph-JSON presets now select tricu tree +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 · +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 +refused rather than emitted as complete files. Reduction needed to observe a +constructor has no fuel/time ceiling and may diverge: Stop terminates the Worker. +Expanded output has a 32 MiB byte bound and DAG has 128 MiB. Preflight rejects +oversized expansion without constructing the exponentially large string. This +complete-tree demand differs deliberately from the retired shallow JSON preview. Pure mode never interprets an IO-shaped result as authority. Browser IO is an explicit profile for existing `tricuIO`, version 1, tags 0/1/2/10/11: @@ -366,23 +370,25 @@ explicit DTO convention. Names are unique, nonempty text (128 scalars); at most output 16,384 scalars. Numeric values must be <= JavaScript's safe-integer maximum. Malformed fields, duplicate names, unknown tags, and invalid scalar values fail closed. A null-prototype dictionary handles keys such as `__proto__` as data. -Tree graph remains available for inspecting the actual representation. +Tree source and DAG interpretations expose the actual representation. ## Export stored definitions -In **Export Definition**, choose a program using the same selector as Playground: -an example, a named package/version and **Load package**, or **Load by bundle -hash…** followed by a whole-file SHA-256 and **Load bundle**. Select its definition -and tree format, then export. -Package and definition selection are shared with Playground; its "Export this -definition" link opens the same panel. Export -reads the authenticated stored tree, not the applied result. Arguments, result -interpretation and IO mode do not affect it; exporting never runs the definition. -The same selection supports Run, Copy selection link, and Export independently. -After a run/Stop has discarded the Worker, Export reloads the pinned bundle and -keeps the selected definition. Package replacement and definition/format changes -invalidate the previous export; source provenance is the full bundle hash plus -its original definition name, displayed alongside every generated file. +Choose **Output → Stored definition**, select a definition and tree +interpretation, then **Show definition**. It reads the authenticated stored tree +without applying arguments, interpreting IO, loading a compiler, or normalizing +that tree. Execution mode is disabled for this target. Local drafts cannot be +silently substituted: choose Evaluated result or restore published source. + +Stored definitions and evaluated results use the same output area, Copy and +Download actions. The provenance label distinguishes them and identifies the +bundle hash/name for stored definitions. Switching target clears the old output; +changing interpretation marks it stale until the next explicit action. A saved +payload retains its own format/filename and is never relabeled by changed controls. +Original-bundle download and selection-link notifications stay in Settings rather +than replacing the result payload. Errors disable copying/downloading a purported +result, except that background compiler diagnostics preserve clearly marked prior +output. Formats: @@ -405,23 +411,24 @@ shows at most 16,384 characters and explicitly identifies partial previews. Expanded formats have a 32 MiB UTF-8 output limit, preflighted over DAG records before unfolding; oversized expansions fail with a suggestion to use DAG. DAG output has a 128 MiB UTF-8 limit. Both traversal and writing are iterative, -including deeply nested trees. Stop, reset, replacement, and pane changes retain -the existing Worker termination boundary; export allows 120 active seconds. +including deeply nested trees. Stop, replacement and page departure retain +the Worker termination boundary; stored-definition output allows 120 active seconds. +Clear output is an idle-only display action and does not cancel an active program. Writers flush bounded text-fragment batches so expanded output does not retain one array entry per logical node. Loading retains its independent 60-second deadline. These output limits remain in force independently of program execution. ## Compile source -The unified **Playground** downloads the authenticated reference compiler bundle -only after an explicit **Compile draft** action. It invokes +The editor downloads the authenticated reference compiler bundle +when idle source compilation, **Compile now**, or expression evaluation needs it. It invokes `source_unit.compileStableModuleDefinitions` with an explicit environment and a standalone source unit. There are no workspace imports or publication operations. The packaged compiler uses stable lowering. Scratch examples: identity, checked identity, pair constructor, two dependency-ordered definitions, and a raw `tricuIO` line-input program. Compile produces a bounded definition selector; -apply a selected definition using the same literal/result/IO codecs as ordinary -packages. The Worker retains compiled **handles**, never serialized program trees. +evaluate expressions against those definitions, or use the optional structured +argument fields, with the same result/IO codecs as ordinary packages. The Worker retains compiled **handles**, never serialized program trees. Only the result spine and up to 16 names (128 scalars each) are decoded. Program normalization can remain deferred until application/inspection. Parser errors retain structured numeric codes, available line/column, and bounded expected @@ -439,7 +446,7 @@ available for user definitions. Contract names are ordinary source names and mus be distinct from top-level definitions in this compiler profile. The checked-identity example is `id x =@nat? x`, applied to natural 42. Both argument -and result checks use `id x@nat? =@nat? x`; more guards cost more work, so larger examples can take longer and need more memory. Failed annotations return error Results; Tree graph shows +and result checks use `id x@nat? =@nat? x`; more guards cost more work, so larger examples can take longer and need more memory. Failed annotations return error Results; Tree source/DAG exposes the failure tag/payload. `id =@nat? (x : x)` checks the **function tree**, not its future output. That tree fails the canonical-natural contract, as do redundant high-zero representations. Contracts inspect tree structure at runtime. @@ -486,28 +493,19 @@ compiled or published program after a failed compilation. ## Sessions and cancellation -The shared pane opens on **Ecosystem**. **Playground** and -**Export Definition** are explicit -choices, with keyboard Arrow keys/Home/End and ARIA tab/panel relationships. -Inactive panels are inert and removed from layout. The shared pane follows the -active panel's natural height, including opened settings, traces, and result views. -Inputs and source persist when switching. Leaving a running pane stops its Worker; -an idle compiled session remains until discarded or replaced by a package session. - -The catalogue/curated bundle load starts on first entering Playground or Export -Definition. Neither entry runs a program. Selection links stay on Ecosystem -initially and select metadata after entering either package panel. Export has its -own definition/format controls, bounded preview, Stop, Copy, and Download actions. -Switching panels stops in-flight package work, retaining selections and completed -exports; errors appear in the export panel rather than a hidden execution panel. -Compiler downloading/execution for drafts starts with Compile. Explicitly selecting -the indexed compiler package also downloads/verifies its library, without compiling -or executing a program. +The catalogue and selected package load on arrival. Hash selection links load +the exact pinned package/export; no link grants permission to execute it. +The first source edit queues compilation; expression evaluation loads its compiler +on demand. Explicitly selecting the compiler package simply loads that library. +Opening/closing Settings or scrolling to ecosystem content does not cancel work. +Source remains editable during compilation; active evaluation/IO locks editing +until completion or Stop. Page departure, Stop or replacement terminates the +owning Worker. There are no tab-change or hidden-panel lifecycle rules. Compilation and program execution have no configurable node, evaluator-frame, work or active-time ceilings. The former optional-limit editors, settings and Worker request fields have been removed, rather than retained as hidden defaults. -Garbage collection remains enabled. Stop and leaving the pane terminate running +Garbage collection remains enabled. Stop and page departure terminate running Workers; actual browser/OS memory restrictions still apply. A divergent program can run until cancelled, and a large live result can exhaust browser memory. @@ -519,9 +517,7 @@ bounds remain enabled. Native tools remain preferable for large builds. Failures retain their underlying diagnostic with the operation named. Timeouts identify loading, download or definition export; compilation and runs do not -have elapsed-time cutoffs. Manual Stop and pane-change cancellation remain -cancellation messages rather than operation failures. The Playground's **Export this definition** -link follows Interaction trace at the bottom of Try this program. +have elapsed-time cutoffs. Manual Stop reports cancellation rather than an operation failure. ## Ordinary package resource and safety boundaries @@ -575,7 +571,7 @@ actual bytes without recompiling programs or relabeling compiler fixtures. All executable downloads use same-origin **`GET /_arboricx/bundle/hash/<sha256>`**, including curated examples and the compiler. There is no static `bundles/` fallback. Metadata keeps pins, not local download paths. Downloads remain deferred: -Playground on entry, compiler only on Compile. Serve `.js` as JavaScript. Relative +The selected package loads on arrival; the compiler loads for source compilation or expression evaluation. Serve `.js` as JavaScript. Relative static paths support subdirectory deployment; the registry route is origin-rooted. Workers/Web Crypto require HTTPS or trusted localhost; `file://` is unsupported. No CDN, analytics, external font or runtime dependency is loaded. @@ -627,7 +623,7 @@ separate from those tests. The hash is plain SHA-256 of **whole bundle bytes**, not a domain-separated CLI module-manifest/export CAS pin. Requests use the same-origin registry route, omit credentials, and reject redirects. A selection link uses the page fragment -for bundle/export (and an optional curated entry ID); entering Playground loads +for bundle/export (and an optional curated entry ID); arrival loads metadata but never automatically executes. Literal inputs are not included in selection links. ## Compiler provenance @@ -723,9 +719,8 @@ These steps prepare and validate local release assets, not a public deployment. critical sections; noncritical extensions can be skipped. - `src/codecs.ts`: canonical codecs corresponding to `src/Runtime.hs`, including Unicode **scalar** strings (not byte/UTF-16 lists), bounded BigInt naturals, - structured literal validation, and bounded views. Flat ternary expansion exists - only as an explicitly bounded conformance/debug operation, never evaluator - transport. List view limits and natural/string validation fail explicitly. + structured literal validation, and bounded views. Complete tree output is serialized from a bounded snapshot by + `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, @@ -814,11 +809,11 @@ TEST_BACKEND=zig-wasm ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpk # Heavy compiler comparisons: explicit opt-in, not included in *.test.mjs nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 ext/demo-site/test/compiler-conformance.mjs nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 ext/demo-site/test/contracts-conformance.mjs -nix shell --inputs-from . nixpkgs#nodejs -c python3 ext/demo-site/test/run-bounded.py \ +ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c python3 ext/demo-site/test/run-bounded.py \ --seconds 300 -- node --max-old-space-size=512 ext/demo-site/test/browser.mjs chromium -# Optional narrow screenshot as a final argument: -# node ext/demo-site/test/browser.mjs chromium /tmp/arboricx-browser.png -# Also writes workspace and export screenshots beside it. +# Optional screenshot prefix as a final argument: +# node ext/demo-site/test/browser.mjs chromium /tmp/arboricx-editor +# Writes -390.png and -1440.png full-page views. # Optional deterministic cost screen against a caller-selected retained prior site: nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 \ ext/demo-site/test/demo-cost.mjs "${REFERENCE_SITE:?select a retained prior site directory}" @@ -849,12 +844,17 @@ Haskell result/terminal goldens, all eight source/environment profiles, seven codecs and negative controls, physical sharing, nonforcing divergent preview and abort payloads, IO limits and GC across input waits, compile/host-error recovery, and 50 retained runs with stable owned-handle counts and linear-memory plateau. -Complete bounded observation is an explicit test operation, never a checkpoint -or the implementation of ordinary Run. These are site-capability parity claims, +Complete bounded observation is used for tree-file result interpretations and +conformance tests, never as a heap checkpoint. Scalar interpretations retain their +separate bounded codecs. These are site-capability parity claims, not a claim that the two private embedding protocols expose identical operations. ### Matched production-backend benchmark +These measurements predate live compilation and expression input. They measure +explicit compilation and structured-argument Worker calls, not current +expression-to-result latency. + Use a built site, not the prototype Worker. The harness alternates backend order across three fresh browser profiles per backend/engine, loads all eight source demos as published packages, then compiles each in a fresh production Worker. @@ -985,3 +985,25 @@ Worker termination, and narrow layouts. Sources use the repository ISC license. No third-party evaluator/runtime code is vendored. + +### Single-page editor regression checks + +The actual-page probe covers all eight demo application goldens as expressions, +source-free library calls, local bindings, temporary-name isolation, diagnostics, +IO, unused divergent definitions and hard Stop. Controlled late Worker replies +exercise edit coalescing and stale success/error rejection; checks also cover +IME composition, previous-output retention, package changes and backend rebuilds. +The suite also checks stored/result provenance, all four tree representations, +IO result serialization, complete copy/download behind a bounded visible preview, +expansion refusal, immutable bundle download, pin failures/retries and viewport +edge/focus behavior. No compiler or evaluator is substituted by the probe. The +former tab-oriented UI expectations were retired; lower-level codec, package, +security and runtime conformance controls remain. + +```sh +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. +# 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,8 +1,11 @@ +import { printLiteral } from './literal-input.js'; import { backend, backends, graphStats, runtimeStats } from './runtime-backends.js'; import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; -import { createTreeExport } from './export-app.js'; -import { activePane } from './tabs.js'; +import { createOutput } from './output-app.js'; +import { setupInspector } from './inspector.js'; +import { isTreeResult } from './tree-result.js'; +import { treeFormat } from './tree-export.js'; import { ActiveClock } from './clock.js'; import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout } from './execution.js'; import { sourceText } from './compiler.js'; @@ -19,12 +22,38 @@ function el(id) { 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 guidedInputs = el('use-guided-inputs'); +let editRevision = 0, composing = false, idleDue = 0; +let idleTimer; +function cancelIdleCompile() { clearTimeout(idleTimer); idleTimer = undefined; } +function queueCompile(delay = 650) { + cancelIdleCompile(); + if (composing) + return; + idleDue = performance.now() + delay; + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (!busy && draft) + compileDraft(); + }, delay); +} +function markPreviousResult() { + 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(' '); +} +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); let draft = false, scratch = false, compiled = false, busy = false, originalSource; -let compiledDraft, compiledSource; +let compiledDraft, compiledSource, compiledRevision; let totalWork = 0; let compilationStats = '', preferredName = 'id'; let compilerTimer; @@ -41,18 +70,30 @@ const runButton = el('run'), stopButton = el('stop'), nextButton = el('next'); let operation; const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); const shareButton = el('share'); -const treeExport = createTreeExport(message => { - if (draft || !loaded || !selection) +const outputUI = createOutput(); +const outputTarget = el('output-target'); +const storedOutput = () => outputTarget.value === 'definition'; +const visibleCodec = (value) => value === 'tree' || value === 'list' ? 'tricu' : value; +let resultInterpretation = 'tricu'; +function clearTreeExport() { if (storedOutput()) + outputUI.clear(); } +function syncOutputTarget() { + for (const option of resultCodec.options) + option.disabled = storedOutput() && !isTreeResult(option.value); + if (storedOutput() && !isTreeResult(resultCodec.value)) + resultCodec.value = 'tricu'; + controls(busy); +} +outputTarget.onchange = () => { + if (busy) return; - if (worker) - startExport(message, value => worker?.postMessage(value)); + if (storedOutput()) + resultInterpretation = resultCodec.value; else - newWorker(false, true, message); -}, name => { - exportsInput.value = name; - exportsInput.dispatchEvent(new Event('change')); -}, () => finish('Stopped. Export session discarded.', true, 'cancelled')); -const clearTreeExport = treeExport.clear; + resultCodec.value = resultInterpretation; + clearOutput(); + syncOutputTarget(); +}; const inputForm = el('input-form'), lineInput = el('line-input'); let entries = [], selected; let selection; @@ -72,51 +113,59 @@ function controls(working) { hashInput.disabled = busy; el('load-release').disabled = busy || !programPackage(programPicker.value); el('load').disabled = busy; - runButton.disabled = draft ? !compiled || (busy && persistentDefinitions()) : busy || !loaded; - runButton.textContent = draft ? 'Run draft' : 'Run published'; - el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource); + runButton.disabled = busy || (storedOutput() ? draft || !loaded : draft ? !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision : !loaded); + runButton.textContent = storedOutput() ? 'Show definition' : 'Evaluate'; + el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); el('draft-run-note').textContent = compiled - ? 'Run draft uses the last successful compilation. Compile draft to apply source edits.' - : 'Draft not compiled. Compile draft, then run it; published programs are not used as a fallback.'; + ? 'Source changed. Evaluation waits for the current revision to compile.' + : 'Waiting for compilation. Published programs are never used as a fallback.'; argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); - el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; + el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; + nextButton.hidden = storedOutput() || !steps.checked; runButton.classList.toggle('draft', draft); runButton.classList.toggle('primary', !draft || compiled); - compileButton.classList.toggle('primary', !compiled); - compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource); + compileButton.classList.remove('primary'); + compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); compileButton.hidden = !draft; restoreButton.hidden = !draft; - sourceEditor.disabled = busy; + sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); - el('source-summary').textContent = editableSource ? 'Source and editing' : sourceAvailable ? 'Source (read-only)' : 'Package details'; - el('source-title').textContent = editableSource ? 'Read and edit source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; + el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; el('source-availability').hidden = editableSource || !loaded; el('source-availability').textContent = sourceAvailable - ? 'Source is shown for reference only. This package does not support editing or local compilation.' - : 'This is a compiled package. Editable source is not included; you can run or export its stored definitions.'; + ? 'Source is shown for reference only; the published implementation cannot be edited here. Evaluate expressions against its exports below.' + : 'This is a compiled package. Editable source is not included; you can evaluate expressions against its exports or export its stored definitions.'; restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || downloadHash() === undefined; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; - const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource); + const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision); el('authorship').classList.toggle('modified', sourceModified); sourceEditor.classList.toggle('modified', sourceModified); - el('export-draft-note').hidden = !draft; + 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('[data-compiler-example]')) button.disabled = busy; - stopButton.disabled = !busy && !(draft && (worker || compiled)); - el('stop-compile').disabled = stopButton.disabled; - el('stop-compile').hidden = !draft; + stopButton.disabled = !busy && !draft; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); - mode.disabled = busy; + expressionEditor.disabled = busy && operation !== 'compiling'; + guidedInputs.disabled = busy; + el('expression-inputs').hidden = guidedInputs.checked; + el('guided-inputs').hidden = !guidedInputs.checked; + mode.disabled = busy || storedOutput(); + outputTarget.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy; shareButton.disabled = busy || draft || !loaded; - treeExport.controls(busy, loaded && !draft); + el('reset').disabled = busy; + outputUI.controls(busy); } function dispose(preserveDraft = false) { + cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; @@ -125,6 +174,7 @@ function dispose(preserveDraft = false) { if (!preserveDraft) { compiledDraft = undefined; compiledSource = undefined; + compiledRevision = undefined; } compiled = compiledDraft !== undefined; pending = undefined; @@ -133,12 +183,12 @@ function dispose(preserveDraft = false) { controls(false); } function clearOutput() { + el('result-stale').hidden = true; + el('compile-diagnostic').hidden = true; el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = ''; - el('result-inspection').hidden = true; - el('graph-result').textContent = ''; - el('graph-result').hidden = true; + outputUI.clear(); el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; @@ -147,18 +197,29 @@ function clearOutput() { function finish(message, error = false, reason = 'failure') { if (error && reason === 'failure') message = operationFailure(operation, message); + if (operation === 'compiling' && reason === 'failure') { + dispose(); + el('status').textContent = 'Compilation failed'; + el('compiler-stats').textContent = 'Compilation failed; runtime discarded.'; + el('compile-diagnostic').textContent = message; + el('compile-diagnostic').hidden = false; + return; + } if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; - if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) - treeExport.fail(message); + 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'; - el('result').textContent = message; - el('result').className = error ? 'error' : ''; + if (!downloadOnly) { + el('result').textContent = message; + el('result').className = error ? 'error' : ''; + } for (const row of traceRows.values()) row.classList.remove('pending'); } @@ -167,12 +228,13 @@ function setPreset(entry, name = entry.export) { argumentsUI.labels(demoInputLabels(entry.id, name)); argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); - resultCodec.value = preset.output; + resultCodec.value = visibleCodec(preset.output); mode.value = preset.mode; + presetExpression(name); } function startExport(message, send) { operation = 'exporting'; - treeExport.begin(); + clearOutput(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock(); @@ -217,6 +279,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { case 'loaded': { currentVerified = true; loaded = true; + el('compiler-stats').textContent = 'Published package · no local compilation.'; const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export); exportsInput.replaceChildren(); for (const item of data.exports) { @@ -228,7 +291,6 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { if (oldExport && data.exports.some(e => e.name === oldExport)) exportsInput.value = oldExport; preferredExport = undefined; - treeExport.selection(exportsInput); el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`; if (!preserveInputs && !automaticRun && !exportAfterLoad) { originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; @@ -236,6 +298,10 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { sourceEditor.hidden = originalSource === undefined; el('source-notes').hidden = originalSource !== undefined; } + if (!preserveInputs && !automaticRun && !exportAfterLoad) + presetExpression(); + if (storedOutput() && !isTreeResult(resultCodec.value)) + resultCodec.value = 'tricu'; 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.`; @@ -261,19 +327,16 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { } case 'exported': { pauseClock(); - if (!treeExport.receive(data, selection?.hash, exportsInput.value)) + 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}`); operation = undefined; controls(false); el('status').textContent = 'Ready'; break; } case 'export-error': - pauseClock(); - treeExport.fail(operationFailure('exporting', data.message)); - operation = undefined; - controls(false); - el('status').textContent = 'Ready'; + finish(data.message, true); break; case 'trace': { el('trace-empty').hidden = true; @@ -300,15 +363,13 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { el('terminal').scrollTop = el('terminal').scrollHeight; break; case 'done': { - const { result, stats } = data; - if (result.type === 'tree' || result.type === 'list') { - el('result-inspection').hidden = false; - el('graph-result').hidden = false; - el('graph-result').textContent = JSON.stringify(result.value, null, 2); - finish(`Returned ${result.type}: tree graph · ${runtimeStats(stats)}`); - } + if (persistentDefinitions()) + idleDraft(); else - finish(`Returned ${result.type}: ${JSON.stringify(result.value)}`); + dispose(); + el('status').textContent = 'Finished'; + outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`); + el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`; break; } case 'error': @@ -344,8 +405,6 @@ function select(entry, release) { clearTreeExport(); selected = entry; loaded = false; - el('source-section').open = true; - el('try-program').open = true; selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash; sourceEditor.hidden = true; @@ -371,7 +430,6 @@ function invalidateProgram() { selected = undefined; preferredExport = undefined; exportsInput.replaceChildren(); - treeExport.selection(exportsInput); sourceEditor.hidden = true; el('source-notes').hidden = false; el('source').textContent = 'Load the selected program to inspect its source or package notes.'; @@ -382,8 +440,6 @@ function invalidateProgram() { } function loadBundle(release) { const value = release?.hash ?? hashInput.value; - el('source-section').open = true; - el('try-program').open = true; invalidateProgram(); chooseProgram(release ? releaseProgram(release) : 'hash', release?.version); hashInput.value = value; @@ -392,7 +448,7 @@ function loadBundle(release) { selection = { hash }; argsInput.value = '[]'; argumentsUI.refresh(true); - resultCodec.value = 'tree'; + resultCodec.value = 'tricu'; mode.value = 'pure'; showRelease(release); el('source').textContent = 'Verifying registry package…'; @@ -405,9 +461,24 @@ function loadBundle(release) { } el('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); }; hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; -exportsInput.onchange = () => { preferredName = exportsInput.value; clearTreeExport(); treeExport.selection(exportsInput); if (!scratch && selected?.presets?.[exportsInput.value]) - setPreset(selected, exportsInput.value); }; +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 (storedOutput()) { + if (draft || !loaded || !selection) + return; + const request = { type: 'export', export: exportsInput.value, format: treeFormat(resultCodec.value) }; + if (worker) + startExport(request, value => worker?.postMessage(value)); + else + newWorker(false, true, request); + return; + } if (draft) { runDraft(); return; @@ -417,8 +488,8 @@ runButton.onclick = () => { operation = 'running'; clearOutput(); try { - const args = argumentsUI.read(), output = outputType(resultCodec.value); - savedRun = { type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; + const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); + savedRun = { type: 'run', ...expressionInput(), export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; if (worker) { controls(true); runClock(); @@ -461,9 +532,8 @@ 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'); -el('stop-compile').onclick = stopButton.onclick; -el('try-program-link').onclick = () => { el('try-program').open = true; }; -el('reset').onclick = () => { keepDraft(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +el('reset').onclick = () => { if (busy) + return; clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; @@ -473,10 +543,10 @@ shareButton.onclick = async () => { url.hash = fragment.toString(); try { await navigator.clipboard.writeText(url.href); - el('result').textContent = 'Copied a link to this package and export.'; + el('bundle-download-status').textContent = 'Copied a link to this package and export.'; } catch { - el('result').textContent = url.href; + el('bundle-download-status').textContent = url.href; } }; window.addEventListener('pagehide', () => dispose()); @@ -633,8 +703,6 @@ downloadButton.onclick = () => { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; el('bundle-download-status').textContent = message; - el('result').textContent = message; - el('result').className = ''; } else if (data.type === 'error') finish(data.message, true); @@ -643,12 +711,42 @@ downloadButton.onclick = () => { finish(event.message || 'Worker error', true); }; current.postMessage({ type: 'load', hash }); }; -mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; }; +resultCodec.onchange = markPreviousResult; +argsInput.addEventListener('input', markPreviousResult); +el('argument-fields').addEventListener('input', markPreviousResult); +mode.onchange = () => { markPreviousResult(); el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; }; +expressionEditor.oninput = markPreviousResult; +guidedInputs.onchange = () => { + if (!guidedInputs.checked) { + try { + presetExpression(); + } + catch (error) { + guidedInputs.checked = true; + outputUI.clear(); + el('result').textContent = String(error); + } + } + markPreviousResult(); + controls(busy); +}; +sourceEditor.addEventListener('compositionstart', () => { composing = true; cancelIdleCompile(); }); +sourceEditor.addEventListener('compositionend', () => { composing = false; if (draft) + queueCompile(); }); sourceEditor.oninput = () => { - el('source-section').open = true; + if (busy && operation !== 'compiling') + return; + editRevision++; + markPreviousResult(); + el('compile-diagnostic').hidden = true; + if (operation === 'compiling') { + draft = true; + controls(true); + queueCompile(); + return; + } preferredName = exportsInput.value || preferredName; keepDraft(); - clearOutput(); clearTreeExport(); if (!compiled) exportsInput.replaceChildren(); @@ -658,8 +756,10 @@ sourceEditor.oninput = () => { preferredExport = preferredName; newWorker(false, true); } - else - el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; + else { + el('status').textContent = 'Changes pending'; + queueCompile(); + } }; restoreButton.onclick = () => { if (originalSource === undefined) @@ -671,15 +771,17 @@ restoreButton.onclick = () => { }; compileButton.onclick = () => compileDraft(); function compileDraft() { - if (!draft) + if (!draft || busy) return; + const snapshot = { revision: editRevision, source: sourceEditor.value }; dispose(true); operation = 'compiling'; - clearOutput(); + markPreviousResult(); clearTreeExport(); + el('compile-diagnostic').hidden = true; try { sourceText(sourceEditor.value); - const current = draftWorker(); + const current = draftWorker(snapshot); controls(true); deadline.reset(Infinity); clock(); @@ -689,26 +791,37 @@ function compileDraft() { const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; }; activity(); compilerTimer = setInterval(activity, 1000); - current.postMessage({ type: 'compile', source: sourceEditor.value, + current.postMessage({ type: 'compile', source: snapshot.source, ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } } -function draftWorker() { +function draftWorker(snapshot) { const current = new Worker(workerURL(true), { type: 'module' }); worker = current; + const discardStale = () => { + 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 }) => { if (worker !== current) return; + if ((data.type === 'compiled' || data.type === 'error') && discardStale()) + return; switch (data.type) { case 'compiled': stopCompilerActivity(); pauseClock(); operation = undefined; compiledDraft = data.draft; - compiledSource = sourceEditor.value; + compiledSource = snapshot.source; + compiledRevision = snapshot.revision; compiled = true; totalWork = graphStats(data.stats) ? data.stats.work : 0; exportsInput.replaceChildren(); @@ -760,19 +873,13 @@ function draftWorker() { else dispose(true); el('status').textContent = 'Finished'; - if (data.result.type === 'tree' || data.result.type === 'list') { - el('result-inspection').hidden = false; - el('graph-result').hidden = false; - el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); - el('result').textContent = `Returned ${data.result.type}: tree graph`; - } - else - el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; + 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)}`; break; case 'error': if (persistentDefinitions() && operation === 'running' && !data.fatal) { idleDraft(); + outputUI.clear(); el('status').textContent = 'Stopped'; el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; @@ -784,7 +891,8 @@ function draftWorker() { }; current.onerror = event => { if (worker === current) { event.preventDefault(); - finish(event.message || 'Compiler Worker failed.', true); + if (!discardStale()) + finish(event.message || 'Compiler Worker failed.', true); } }; return current; } @@ -806,10 +914,11 @@ function keepDraft() { dispose(true); } function runDraft() { - if (!compiled) + if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) return; try { - const args = argumentsUI.read(), output = outputType(resultCodec.value); + const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); + const expression = expressionInput(); if (!persistentDefinitions()) dispose(true); else if (!worker || busy) @@ -822,9 +931,11 @@ function runDraft() { const current = persistentDefinitions() ? worker : draftWorker(); if (!persistentDefinitions()) current.postMessage({ type: 'restore', draft: compiledDraft }); - current.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); + 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'; keepDraft(); @@ -832,11 +943,13 @@ function runDraft() { } backendPicker.onchange = () => { selectedBackend = backend(backendPicker.value); + editRevision++; dispose(); - clearOutput(); + markPreviousResult(); if (draft) { controls(false); - el('status').textContent = 'Backend changed · compile draft to run'; + el('status').textContent = 'Changes pending'; + queueCompile(0); } else if (selection) newWorker(false, true); @@ -854,7 +967,6 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) if (!example) return; chooseProgram(''); - el('source-section').open = true; keepDraft(); clearOutput(); clearTreeExport(); @@ -863,17 +975,22 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) originalSource = undefined; if (!compiled) exportsInput.replaceChildren(); + editRevision++; preferredName = example.name; sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source; argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); - resultCodec.value = example.output; + resultCodec.value = visibleCodec(example.output); mode.value = example.mode; + presetExpression(example.name); controls(false); + el('package-info').textContent = 'Scratch program · no published bundle selected.'; + el('release-info').textContent = ''; el('source-label').textContent = 'Scratch program · contract helpers'; - el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; + el('status').textContent = 'Changes pending'; + queueCompile(); }; function loadFragment() { const fragment = new URLSearchParams(location.hash.slice(1)); @@ -898,7 +1015,7 @@ function loadFragment() { selected = undefined; argsInput.value = '[]'; argumentsUI.refresh(true); - resultCodec.value = 'tree'; + resultCodec.value = 'tricu'; mode.value = 'pure'; selection = { hash }; hashInput.value = hash; @@ -907,7 +1024,7 @@ function loadFragment() { return true; } window.addEventListener('hashchange', () => { - if (!entries.length || !packagePane()) + if (!entries.length) return; try { if (new URLSearchParams(location.hash.slice(1)).has('bundle')) @@ -918,7 +1035,6 @@ window.addEventListener('hashchange', () => { finish(error instanceof Error ? error.message : String(error), true); } }); -function packagePane() { return activePane() === 'playground' || activePane() === 'export-definition'; } let initialized = false; async function initializePackages() { if (initialized) @@ -951,7 +1067,7 @@ async function initializePackages() { const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576); releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes))); populatePrograms(); - if (!packagePane() || selection || draft) { + if (selection || draft) { operation = undefined; return; } @@ -968,22 +1084,5 @@ async function initializePackages() { finish(error instanceof Error ? error.message : String(error), true); } } -window.addEventListener('pane-change', () => { - // Moving between panels cancels active work, but retains verified selection and - // completed exports. Move the single package chooser rather than mirror inputs. - if (worker) { - if (busy) - finish('Stopped. Start a new operation in the selected tab.', true, 'cancelled'); - else if (!draft) { - dispose(); - el('status').textContent = loaded ? 'Ready' : 'Load package'; - } - } - if (packagePane()) { - document.querySelector(`#${activePane()} [data-package-slot]`).append(el('package-selection')); - if (!initialized) - void initializePackages(); - else if (!draft && entries.length && !loaded && selection) - newWorker(false); - } -}); +setupInspector(); +void initializePackages(); diff --git a/ext/demo-site/dist/codecs.js b/ext/demo-site/dist/codecs.js @@ -1,3 +1,4 @@ +import { treeResult } from './tree-result.js'; export const TEXT_LIMIT = 4096; export function natural(graph, value) { if (value < 0n || value.toString(2).length > 4096) @@ -128,12 +129,16 @@ export function makeLiteral(graph, value) { } } export function outputType(input) { - if (!['tree', 'string', 'number', 'bool', 'bytes', 'list', 'record'].includes(String(input))) + if (!['tree', 'string', 'number', 'bool', 'bytes', 'list', 'record', 'tricu', 'triangle', 'dag', 'ternary'].includes(String(input))) throw new Error('Unsupported output codec'); return input; } export function view(graph, handle, type) { switch (type) { + case 'tricu': + case 'triangle': + case 'dag': + case 'ternary': return treeResult(graph.snapshot(handle), type); case 'record': return { type, value: readRecord(graph, handle) }; case 'tree': return { type, value: graph.preview(handle) }; case 'string': return { type, value: readText(graph, handle) }; diff --git a/ext/demo-site/dist/compiler-worker.js b/ext/demo-site/dist/compiler-worker.js @@ -1,3 +1,4 @@ +import { compileExpression } from './expression.js'; import { resumeValue } from './io.js'; import { environmentProfile } from './packages.js'; import { BROWSER_BUDGET, collectionPolicy } from './execution.js'; @@ -6,7 +7,7 @@ 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, compiledDefinitions } from './compiler.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); @@ -66,13 +67,13 @@ async function run(message) { throw new Error('Compile a unit first'); phase = 'running'; running(); - const fn = exports.get(message.export); + 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 = graph.apply(fn, args); + 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) @@ -120,6 +121,7 @@ scope.onmessage = ({ data }) => { 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 diff --git a/ext/demo-site/dist/export-app.js b/ext/demo-site/dist/export-app.js @@ -1,102 +0,0 @@ -import { treeFormat } from './tree-export.js'; -function el(id) { - const element = document.getElementById(id); - if (!element) - throw new Error(`Missing element ${id}`); - return element; -} -// Presentation owns its output and invalidation; the package controller owns the -// shared selection and worker. No arguments or execution settings enter exports. -export function createTreeExport(request, select, stop) { - const definition = el('tree-definition'), format = el('tree-format'); - const button = el('export-tree'), cancel = el('stop-tree'); - const copy = el('copy-tree'), download = el('download-tree'); - const preview = el('tree-preview'), status = el('tree-provenance'), empty = el('tree-empty'); - let exported, pending = false; - const descriptions = { - ternary: 'A single line: 0 is Leaf, 1 introduces a Stem, and 2 introduces a Fork. Shared branches are repeated.', - tricu: 'A fully parenthesized tree expression using t. This is generated tree structure, not the original source.', - triangle: 'A fully parenthesized tree expression using △. Shared branches are repeated.', - dag: 'Shared △ applications with a fixed export named tree. Choose DAG when an expanded tree would be too large.', - }; - function clear() { - exported = undefined; - pending = false; - preview.textContent = ''; - preview.hidden = true; - empty.hidden = false; - status.textContent = 'Export the selected stored definition.'; - status.classList.remove('error'); - copy.disabled = true; - download.disabled = true; - } - function help() { el('tree-format-help').textContent = descriptions[treeFormat(format.value)]; } - function begin() { clear(); pending = true; status.textContent = 'Preparing definition export…'; } - function fail(message) { clear(); status.textContent = message; status.classList.add('error'); } - format.onchange = () => { clear(); help(); }; - definition.onchange = () => { clear(); select(definition.value); }; - button.onclick = () => { - try { - begin(); - request({ type: 'export', export: definition.value, format: treeFormat(format.value) }); - } - catch (error) { - fail(error instanceof Error ? error.message : String(error)); - } - }; - cancel.onclick = stop; - copy.onclick = async () => { - const value = exported; - if (!value) - return; - try { - await navigator.clipboard.writeText(value.file.text); - if (exported === value) - status.textContent += ' · Copied complete tree.'; - } - catch { - if (exported === value) - status.textContent += ' · Clipboard unavailable. Use Download tree.'; - } - }; - download.onclick = () => { - if (!exported) - return; - const url = URL.createObjectURL(new Blob([exported.file.text], { type: 'text/plain;charset=utf-8' })); - const anchor = document.createElement('a'); - anchor.href = url; - const name = exported.export.replace(/[^a-zA-Z0-9_-]/gu, '-').slice(0, 64) || 'tree'; - anchor.download = `${name}-${exported.hash.slice(0, 12)}.${exported.file.extension}`; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - }; - help(); - return { - clear, begin, fail, - get pending() { return pending; }, - selection(input) { - definition.replaceChildren(...[...input.options].map(option => option.cloneNode(true))); - definition.value = input.value; - }, - controls(busy, loaded) { - button.disabled = format.disabled = definition.disabled = busy || !loaded; - cancel.disabled = !busy; - copy.disabled = download.disabled = busy || !exported; - }, - receive(data, hash, name) { - if (data.hash !== hash || data.export !== name || data.format !== format.value) - return false; - exported = data; - pending = false; - preview.textContent = data.file.text.slice(0, 16_384); - preview.hidden = false; - empty.hidden = true; - status.classList.remove('error'); - status.textContent = `SHA-256 ${data.hash} · definition ${data.export} · ${data.file.bytes.toLocaleString()} UTF-8 bytes · ${data.file.storedNodes.toLocaleString()} stored nodes` + - (preview.textContent.length < data.file.text.length ? ' · Preview: first 16,384 characters. Copy and Download contain the complete tree.' : ' · Complete tree.'); - return true; - }, - }; -} diff --git a/ext/demo-site/dist/expression.js b/ext/demo-site/dist/expression.js @@ -0,0 +1,38 @@ +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'; +// 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) { + const expression = sourceText(input), occupied = new Set(names); + let i = 0, 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, 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; + return new Error(error.message.replace(/(Compiler diagnostic \d+) at (\d+):(\d+)/, (_match, label, line, column) => `${label} at expression ${Math.max(1, Number(line) - 1)}:${Math.max(1, Number(column) - 2)}`)); +} diff --git a/ext/demo-site/dist/graph.js b/ext/demo-site/dist/graph.js @@ -314,10 +314,24 @@ export class Graph { preview(handle, limit = 64) { if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid preview budget'); + return this.capture(handle, limit); + } + snapshot(handle, limit = 100_000) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100_000) + throw new Error('Invalid snapshot budget'); + return this.capture(handle, limit); + } + capture(handle, limit) { const root = this.force(handle), seen = new Set(), pending = [root]; const nodes = [], aliases = []; const forwarded = new Set(); - while (pending.length && nodes.length < limit) { + while (pending.length) { + if (seen.has(pending.at(-1)) || forwarded.has(pending.at(-1))) { + pending.pop(); + continue; + } + if (nodes.length >= limit) + break; const handle = pending.pop(), { id, node } = this.observe(handle); if (id !== handle && !forwarded.has(handle)) { forwarded.add(handle); diff --git a/ext/demo-site/dist/haskell-session.js b/ext/demo-site/dist/haskell-session.js @@ -1,3 +1,5 @@ +import { isTreeResult, treeResult } from './tree-result.js'; +import { expressionUnit, expressionDiagnostic } from './expression.js'; // Backend-private protocol mapping. No WASM pointer, Haskell heap checkpoint or // fabricated graph statistic crosses into the shared site protocol. import { compilerDescriptor } from './compiler.js'; @@ -29,6 +31,7 @@ export class HaskellSession { } } compilerHash = null; + names = []; get disposed() { return this.client.disposed; } async request(message) { const reply = await this.client.request(message, null); @@ -36,7 +39,7 @@ export class HaskellSession { return reply; } async json(message) { return JSON.parse((await this.request(message)).text); } - async load(bytes, hash) { await this.request({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } + async load(bytes, hash) { this.names = await this.json({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } async compile(source, profileName) { let environment; if (profileName !== undefined) { @@ -50,13 +53,43 @@ export class HaskellSession { } : null; } const reply = await this.request({ type: 'compile', source, ...(environment === undefined ? {} : { environment }) }); - return reply.text.trimEnd().split('\n').filter(Boolean); + this.names = reply.text.trimEnd().split('\n').filter(Boolean); + return this.names; } async run(name, args, output, mode, host) { + return this.execute({ type: mode === 'pure' ? 'evaluate' : 'io-start', name, arguments: args, output }, output, mode, host); + } + async runExpression(source, output, mode, host) { + const unit = expressionUnit(source, this.names); + if (!this.compilerHash) { + const bytes = await fetchBytes(new URL('../compiler.json', import.meta.url), 4096); + const expected = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))).hash; + const ready = await this.request({ type: 'compiler-init' }); + if (ready.compilerHash !== expected) { + this.dispose(); + throw new Error('Runtime compiler pin mismatch'); + } + this.compilerHash = expected; + } + try { + return await this.execute({ type: 'expression', ...unit, output, mode }, output, mode, host); + } + catch (error) { + throw expressionDiagnostic(error); + } + } + async execute(request, output, mode, host) { + const displayed = async (message) => { + if (isTreeResult(output)) { + const snapshot = await this.json(message); + return treeResult(snapshot.value, output); + } + return this.json(message); + }; if (mode === 'pure') - return this.json({ type: 'evaluate', name, arguments: args, output }); + return displayed(request); try { - let event = await this.json({ type: 'io-start', name, arguments: args }); + let event = await this.json(request); while (event.kind !== 'done') { let value = ''; switch (event.kind) { @@ -77,7 +110,7 @@ export class HaskellSession { host.running(); event = await this.json({ type: 'io-resume', token: event.token, value }); } - return await this.json({ type: 'io-view', output }); + return await displayed({ type: 'io-view', output }); } finally { // Release result/continuations even when decoding or a host hook fails. diff --git a/ext/demo-site/dist/inspector.js b/ext/demo-site/dist/inspector.js @@ -0,0 +1,54 @@ +// A docked inspector on wide screens, an accessible modal sheet on phones. +export function setupInspector() { + const panel = document.getElementById('settings-panel'), toggle = document.getElementById('settings-toggle'); + const close = document.getElementById('settings-close'), backdrop = document.getElementById('settings-backdrop'); + const body = document.getElementById('editor-body'), mobile = matchMedia('(max-width:740px)'); + const background = [...document.querySelectorAll('[data-inspector-background]')]; + let open = false; + function layout() { + panel.hidden = !open; + backdrop.hidden = !open || !mobile.matches; + body.classList.toggle('settings-open', open); + toggle.setAttribute('aria-expanded', String(open)); + panel.setAttribute('role', mobile.matches ? 'dialog' : 'region'); + if (mobile.matches && open) + panel.setAttribute('aria-modal', 'true'); + else + panel.removeAttribute('aria-modal'); + for (const element of background) + element.inert = open && mobile.matches; + document.body.classList.toggle('inspector-modal', open && mobile.matches); + if (open && mobile.matches && !panel.contains(document.activeElement)) + close.focus(); + } + function hide() { open = false; layout(); toggle.focus(); } + toggle.onclick = () => { if (open) + hide(); + else { + open = true; + layout(); + close.focus(); + } }; + close.onclick = backdrop.onclick = hide; + mobile.addEventListener('change', layout); + panel.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + hide(); + } + if (event.key !== 'Tab' || !mobile.matches) + return; + const items = [...panel.querySelectorAll('button,input,select,textarea,a[href],summary,[tabindex="0"]')] + .filter(e => !e.hasAttribute('disabled') && e.getClientRects().length > 0); + const first = items[0], last = items.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } + else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + }); + layout(); +} diff --git a/ext/demo-site/dist/output-app.js b/ext/demo-site/dist/output-app.js @@ -0,0 +1,59 @@ +// One output surface. The saved payload carries its own provenance/format; +// changing controls cannot relabel an earlier result or change its download. +export function createOutput() { + const text = document.getElementById('result'); + const provenance = document.getElementById('output-provenance'); + const copy = document.getElementById('copy-output'); + const download = document.getElementById('download-output'); + let saved, busy = false; + const controls = (working) => { busy = working; copy.disabled = download.disabled = busy || !saved; }; + function clear() { saved = undefined; text.textContent = ''; text.className = ''; provenance.textContent = 'Evaluate an expression, or show a stored definition. Nothing runs automatically.'; controls(busy); } + function show(value, filename, description) { + saved = { text: value, filename }; + text.textContent = value.slice(0, 16_384); + text.className = ''; + provenance.textContent = description + (value.length > 16_384 ? ' · Preview: first 16,384 characters. Copy and Download include the complete output.' : ' · Complete output.'); + controls(busy); + } + function file(value, name, description) { + show(value.text, `${name.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64)}.${value.extension}`, `${description} · ${value.bytes.toLocaleString()} UTF-8 bytes`); + } + function result(value, description) { + if ('file' in value) { + file(value.file, 'result', `${description} · ${value.type}`); + return; + } + if (value.type === 'tree' || value.type === 'list') + throw new Error('Select tricu source or DAG to inspect tree results'); + const content = typeof value.value === 'string' ? value.value : JSON.stringify(value.value, null, 2); + show(content, value.type === 'record' || value.type === 'bytes' ? 'result.json' : 'result.txt', `${description} · ${value.type}`); + } + copy.onclick = async () => { + const value = saved; + if (!value) + return; + try { + await navigator.clipboard.writeText(value.text); + if (saved === value) + provenance.textContent += ' · Copied.'; + } + catch { + if (saved === value) + provenance.textContent += ' · Clipboard unavailable; use Download.'; + } + }; + download.onclick = () => { + if (!saved) + return; + const url = URL.createObjectURL(new Blob([saved.text], { type: 'text/plain;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; + a.download = saved.filename; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + clear(); + return { clear, controls, file, result }; +} diff --git a/ext/demo-site/dist/tabs.js b/ext/demo-site/dist/tabs.js @@ -1,38 +0,0 @@ -const tabs = [...document.querySelectorAll('[role="tab"]')]; -export function activePane() { - return (tabs.find(tab => tab.getAttribute('aria-selected') === 'true')?.dataset['pane'] ?? 'how'); -} -function activate(pane, focus = false) { - const changed = activePane() !== pane; - for (const tab of tabs) { - const selected = tab.dataset['pane'] === pane; - tab.setAttribute('aria-selected', String(selected)); - tab.tabIndex = selected ? 0 : -1; - const panel = document.getElementById(tab.getAttribute('aria-controls')); - panel.classList.toggle('is-active', selected); - panel.inert = !selected; - panel.setAttribute('aria-hidden', String(!selected)); - if (selected && focus) - tab.focus(); - } - if (changed) - window.dispatchEvent(new CustomEvent('pane-change', { detail: pane })); -} -for (const [index, tab] of tabs.entries()) { - tab.onclick = () => activate(tab.dataset['pane']); - tab.onkeydown = event => { - const target = event.key === 'ArrowRight' ? (index + 1) % tabs.length - : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length - : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : undefined; - if (target !== undefined) { - event.preventDefault(); - activate(tabs[target].dataset['pane'], true); - } - }; -} -for (const link of document.querySelectorAll('a[data-open-pane]')) - link.onclick = () => { - activate(link.dataset['openPane']); - }; -// Selection links are metadata, not permission to enter an execution pane. -activate('how'); diff --git a/ext/demo-site/dist/tree-result.js b/ext/demo-site/dist/tree-result.js @@ -0,0 +1,58 @@ +import { exportTree } from './tree-export.js'; +export const RESULT_NODE_LIMIT = 100_000; +export function isTreeResult(value) { + return ['tricu', 'triangle', 'dag', 'ternary'].includes(value); +} +// Backend snapshots describe physical sharing, not structural hash-consing. +// Reject incomplete/cyclic observations instead of exporting a plausible prefix. +export function snapshotBundle(snapshot) { + if (snapshot.truncated) + throw new Error('Result snapshot exceeds 100,000 nodes; refusing a partial export'); + const aliases = new Map(snapshot.aliases.map(a => [a.from, a.to])); + const byId = new Map(snapshot.nodes.map(n => [n.id, n])); + const resolve = (raw) => { + let id = raw; + const seen = new Set(); + while (aliases.has(id)) { + if (seen.has(id)) + throw new Error('Snapshot alias cycle'); + seen.add(id); + id = aliases.get(id); + } + return id; + }; + const nodes = [], mapped = new Map(), active = new Set(); + const root = resolve(snapshot.root), stack = [[root, false]]; + while (stack.length) { + const [raw, leave] = stack.pop(), id = resolve(raw); + if (mapped.has(id)) + continue; + const node = byId.get(id); + if (!node) + throw new Error('Incomplete result snapshot'); + const arity = { leaf: 0, stem: 1, fork: 2 }[node.kind]; + if (arity === undefined || node.children.length !== arity) + throw new Error('Invalid snapshot constructor'); + if (!leave) { + if (active.has(id)) + throw new Error('Cyclic result cannot be exported as a DAG'); + active.add(id); + stack.push([id, true]); + for (const child of [...node.children].reverse()) + stack.push([child, false]); + } + else { + active.delete(id); + const cs = node.children.map(c => mapped.get(resolve(c))); + if (cs.some(c => c === undefined)) + throw new Error('Invalid result snapshot order'); + mapped.set(id, nodes.length); + nodes.push(node.kind === 'leaf' ? { kind: 'leaf' } : node.kind === 'stem' ? { kind: 'stem', child: cs[0] } : { kind: 'fork', left: cs[0], right: cs[1] }); + } + } + return { nodes, roots: [mapped.get(root)], exports: [{ name: 'tree', root: mapped.get(root), kind: 'term', abi: 'arboricx.abi.tree.v1' }], metadata: {} }; +} +export function treeResult(snapshot, format) { + const file = exportTree(snapshotBundle(snapshot), 'tree', format); + return { type: format, value: file.text, file }; +} diff --git a/ext/demo-site/dist/wasm-worker.js b/ext/demo-site/dist/wasm-worker.js @@ -70,12 +70,15 @@ scope.onmessage = ({ data }) => { throw new Error('Unsupported execution mode'); const args = literals(m['arguments']), output = outputType(m['output']); running(); - const result = await session.run(m['export'], args, output, m['mode'], { + const host = { async before(event) { send({ type: 'trace', phase: 'before', event }); if (m['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, - }); + }; + const result = m['expression'] === undefined + ? await session.run(m['export'], args, output, m['mode'], host) + : await session.runExpression(sourceText(m['expression']), output, m['mode'], host); send({ type: 'done', result, stats: session.stats() }); } else if (m['type'] === 'export' && stored) { diff --git a/ext/demo-site/dist/worker.js b/ext/demo-site/dist/worker.js @@ -1,3 +1,5 @@ +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'; @@ -37,13 +39,13 @@ async function run(message) { throw new Error('Load a bundle before running'); phase = 'running'; running(); - const fn = exports.get(message.export); + 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 = graph.apply(fn, args); + 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, { @@ -100,6 +102,7 @@ scope.onmessage = ({ data }) => { 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 diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -7,9 +7,9 @@ <link rel="stylesheet" href="./style.css"><script type="module" src="./dist/app.js"></script> </head> <body> - <header class="wrap"><a class="wordmark" href="#">arbori.cx<span> / portable trees</span></a><nav aria-label="Main"><a href="https://git.eversole.co/James/tricu">Source</a></nav></header> + <header class="wrap" data-inspector-background><a class="wordmark" href="#">arbori.cx<span> / portable trees</span></a><nav aria-label="Main"><a href="https://git.eversole.co/James/tricu">Source</a></nav></header> <main> - <section class="hero wrap"> + <section class="hero wrap" data-inspector-background> <p class="eyebrow">Arboricx / <span class="pronunciation" aria-label="arbor-icks">ˈɑːrbərɪks</span></p> <h1>Portable programs.<br><span>All trees.</span></h1> <p class="intro">Tree Calculus makes functions and data out of the same three shapes. tricu builds a language around them, and Arboricx carries the resulting programs between hosts.</p> @@ -18,117 +18,77 @@ <div><svg viewBox="0 0 100 80" aria-hidden="true"><path d="M50 25L50 62"/><circle cx="50" cy="25" r="7"/><circle cx="50" cy="62" r="7"/></svg><strong>Stem</strong><code>t x</code></div> <div><svg viewBox="0 0 100 80" aria-hidden="true"><path d="M50 25L25 62M50 25L75 62"/><circle cx="50" cy="25" r="7"/><circle cx="25" cy="62" r="7"/><circle cx="75" cy="62" r="7"/></svg><strong>Fork</strong><code>t x y</code></div> </div> - <a class="button primary" href="#playground" data-open-pane="playground">Explore a packaged tree ↓</a> + <a class="button primary" href="#editor">Start writing tricu ↓</a> </section> - <section class="workspace wrap" aria-label="Explore Arboricx and tricu"> - <div class="workspace-tabs" role="tablist" aria-label="Explore"> - <button id="tab-how" type="button" role="tab" aria-controls="how" aria-selected="true" data-pane="how">Ecosystem</button> - <button id="tab-playground" type="button" role="tab" aria-controls="playground" aria-selected="false" tabindex="-1" data-pane="playground">Playground</button> - <button id="tab-export-definition" type="button" role="tab" aria-controls="export-definition" aria-selected="false" tabindex="-1" data-pane="export-definition">Export</button> - </div> - <div class="workspace-panels"> - <section id="playground" role="tabpanel" aria-labelledby="tab-playground" tabindex="0" aria-hidden="true" inert class="workspace-panel"> - <div class="section-heading"><div><p class="eyebrow">Real packages, local execution</p><h2>The tree playground.</h2></div></div> - <p class="muted">Choose a program, try its inputs, and run it locally. Read the source, or edit and explicitly compile your own draft.</p> - - - <div data-package-slot><div id="package-selection"> - <div class="program-choice"><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="program-choice"><label for="program-picker">Program</label><select id="program-picker" disabled><option value="">Loading programs…</option></select></div> - <div id="package-toolbar" class="package-toolbar" hidden> - <div id="program-release" class="release-choice" hidden><label for="package-version">Package version</label><div class="input-row"><select id="package-version" aria-label="Exact package version"></select><button id="load-release" type="button" class="primary">Load package</button></div></div> - <form id="program-hash" class="hash-choice" hidden><label for="bundle-hash">Bundle hash (SHA-256)</label><div class="input-row"><input id="bundle-hash" maxlength="64" spellcheck="false" autocomplete="off" aria-describedby="bundle-hash-help"><button id="load" type="submit" class="primary">Load bundle</button></div></form> - <button type="button" id="download-bundle" disabled>↓ Download .arboricx</button> - </div> - <p id="bundle-hash-help" class="hint" hidden>Paste the 64-character hash of an Arboricx bundle to load or download it from this site's registry.</p> - <p id="bundle-download-status" class="hint" role="status"></p> - <section class="bundle-details" aria-label="Bundle details"><p id="release-info" class="hint">No named release selected.</p><p id="package-info" class="hint">Loading program catalogue…</p></section> - </div></div> - <div class="program-workspace"> - <details id="source-section" class="source-disclosure" open><summary id="source-summary">Package details</summary> - <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Package information</h3><span id="source-label">tricu programs</span></div> - <p id="source-availability" class="hint" hidden></p> - <p id="authorship" role="status">Published package</p> - <label class="sr-only" for="source-editor">Editable demo source (up to 16,384 characters)</label> - <textarea id="source-editor" rows="16" maxlength="32768" spellcheck="false" hidden></textarea> - <pre id="source-notes"><code id="source">Loading…</code></pre> - <div class="controls"><button id="compile" type="button" class="primary" disabled hidden>Compile draft</button><button id="stop-compile" type="button" disabled hidden>Stop</button><a id="try-program-link" href="#program-actions">Try this program ↓</a><button id="restore-source" type="button" disabled>Restore published source</button></div> - <p id="compile-activity" class="hint" role="status" hidden></p> - <details class="scratch-examples"><summary>New scratch program · contract helpers included</summary><p class="hint">The compiler is just a tree too. Choose a standalone example, or edit a supplied demo using its pinned environment. No general module resolution.</p><div class="demo-picker" aria-label="Compiler source examples"><button type="button" data-compiler-example="identity">Identity</button><button type="button" data-compiler-example="contracts">Checked identity</button><button type="button" data-compiler-example="pair">Build a pair</button><button type="button" data-compiler-example="definitions">Two definitions</button><button type="button" data-compiler-example="input">Line input</button></div></details> - <details class="compiler-metrics"><summary>Compilation statistics</summary><p id="compiler-stats" class="hint">Compilation starts only when requested.</p></details> - </section> - </details> - <details id="try-program" class="execution-disclosure" open><summary><span class="execution-summary"><span id="execution-title">Try this program</span><span id="status" role="status">Loading</span></span></summary> - <section class="execution-panel" aria-labelledby="execution-title"> - <div class="application-fields"> - <label for="export">Definition</label><select id="export" disabled></select> - <div class="argument-heading"><h3>Inputs</h3><button id="add-argument" type="button">Add input</button></div> - <div id="argument-fields"></div> - </div> - <div id="program-actions" class="controls"><button type="button" id="run" class="primary" disabled>Run published</button><button type="button" id="stop" disabled>Stop</button></div> - <p id="draft-run-note" class="hint" role="status" hidden>Draft not compiled. Compile draft, then run it; published programs are not used as a fallback.</p> - <pre id="terminal" aria-label="Program output" tabindex="0"></pre> - <form id="input-form" hidden><label for="line-input">The program is waiting for 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> - <p id="result" role="status"></p><details id="result-inspection" hidden><summary>Inspect result tree</summary><pre id="graph-result" hidden aria-label="Bounded result graph"></pre></details> - <details class="run-tools"><summary>Advanced</summary> - <details class="argument-help"><summary>Input help and raw JSON</summary> - <p class="hint">One literal per field: <code>42</code>, <code>"hello"</code>, <code>[1 2 3]</code>, or <code>(t t t)</code>. List items use spaces. Names and arbitrary expressions are not evaluated. Remove all fields for no arguments; <code>[]</code> in a field is one empty-list argument.</p> - <label><input id="raw-arguments" type="checkbox"> Edit raw typed JSON instead</label> - <label class="sr-only" for="arguments">Raw arguments (JSON array)</label><textarea id="arguments" rows="5" maxlength="16000" spellcheck="false" hidden>[]</textarea> - <p class="hint">Use [] for its arguments in raw JSON when a definition needs no inputs. Raw types: string, number (decimal string), bool, bytes, tree (ternary), and list.</p> - </details> - <div class="result-codec"><div><label for="output-type">Result interpretation</label><select id="output-type"><option value="tree">Tree graph</option><option value="string">String</option><option value="number">Natural number</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="list">List of trees</option><option value="record">Scalar record</option></select></div></div> - <div><label for="mode">Execution mode</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div><div class="controls"><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy published selection link</button></div> <div class="step-controls"><label><input type="checkbox" id="step-mode"> Pause before each IO action</label><button type="button" id="next" disabled>Next action →</button></div> - <p class="hint">Stop cancels execution. Bundle validation, bounded previews and text output checks remain active. Browser memory is finite; large programs can fail even without an application execution limit.</p> - </details> - <details class="trace-panel"><summary>Interaction trace</summary><section aria-labelledby="trace-title"><div class="panel-heading"><h3 id="trace-title">Interaction tree / execution trace</h3><span>action → response → next action</span></div><p id="trace-empty" class="hint">Choose Text / line IO for an interactive program. Each action, response and continuation appears here.</p><ol id="trace"></ol></section></details> - <p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p> - </section> - </details> - </div> - - - - - - </section> - <section id="export-definition" role="tabpanel" aria-labelledby="tab-export-definition" tabindex="0" aria-hidden="true" inert class="workspace-panel"> - <div class="section-heading"><div><p class="eyebrow">Stored trees, ready to take with you</p><h2>Export a definition.</h2></div></div> - <p class="muted">Save a published definition as a tree. Nothing is run and no arguments are applied. Your package selection is shared with Playground; local drafts are not exported.</p> - <div data-package-slot></div> - <div class="lab tree-export"> - <section class="execution-panel" aria-labelledby="tree-settings-title"> - <div class="panel-heading"><h3 id="tree-settings-title">Choose your export</h3></div> - <div class="application-fields"> - <label for="tree-definition">Stored definition</label><select id="tree-definition" disabled></select> - <label for="tree-format">Tree format</label><select id="tree-format" aria-describedby="tree-format-help" disabled><option value="ternary">Ternary · single line</option><option value="tricu">t expression · tricu source</option><option value="triangle">△ expression</option><option value="dag">DAG · shared △ applications</option></select> - <p id="tree-format-help" class="hint"></p> - </div> - <div class="controls"><button type="button" id="export-tree" class="primary" disabled>Export definition</button><button type="button" id="stop-tree" disabled>Stop</button></div> - <p id="export-draft-note" class="hint" hidden>A local draft is selected. Return to Playground and restore the published source to export its stored definitions.</p> - <p id="tree-provenance" class="hint" role="status">Choose a package and definition.</p> - <details><summary>Export sizes and formats</summary><p class="hint">Expanded formats allow up to 32 MiB; DAG preserves sharing and allows up to 128 MiB. Exports have two minutes of active time; loading allows 60 seconds, including transfer and processing. These files contain tree structure, not an Arboricx bundle or the original program source.</p></details> - </section> - <section class="source-panel" aria-labelledby="tree-preview-title"> - <div class="panel-heading"><h3 id="tree-preview-title">Tree preview</h3><span>Up to 16,384 characters</span></div> - <p id="tree-empty" class="hint">Choose a definition and format, then export to see its tree here.</p> - <pre id="tree-preview" hidden tabindex="0" aria-label="Exported definition preview"></pre> - <div class="controls"><button type="button" id="copy-tree" disabled>Copy tree</button><button type="button" id="download-tree" disabled>Download tree</button></div> - </section> - </div> - </section> - <section id="how" role="tabpanel" aria-labelledby="tab-how" tabindex="0" aria-hidden="false" class="workspace-panel explanation is-active"><p class="eyebrow">One calculus, a growing ecosystem</p><h2>Trees all the way down.</h2><div class="explanation-grid"> +<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> + <span id="status" role="status">Loading Start Here</span> + </div> + <div id="editor-body" class="editor-body"> + <div class="editor-main" data-inspector-background> + <section class="source-panel" aria-labelledby="source-title"> + <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> + <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> + <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> + </section> + </div> + <aside id="settings-panel" aria-labelledby="settings-title" hidden> + <div class="inspector-heading"><h2 id="settings-title">Editor settings</h2><button id="settings-close" aria-label="Close settings">Close</button></div> + <section><h3>Execution</h3> + <label class="check"><input id="use-guided-inputs" type="checkbox"> Use structured arguments</label> + <label class="check"><input id="step-mode" type="checkbox"> Pause before each IO action</label> + <p class="hint">Stop terminates active work. Compilation never runs the program.</p> + <button id="reset">Clear output</button><button id="share" disabled>Copy published selection link</button> + </section> + <details><summary>Structured argument encoding</summary><p class="hint">One literal per field: 42, "hello", [1 2 3], or (t t t). No fields means no arguments. Use raw JSON for exact wire kinds or deeply nested trees.</p><label class="check"><input id="raw-arguments" type="checkbox"> Raw typed JSON</label><label class="sr-only" for="arguments">Typed argument array</label><textarea id="arguments" rows="5" maxlength="16000" spellcheck="false" hidden>[]</textarea></details> + <section id="package-selection"><h3>Published package</h3> + <div id="package-toolbar" hidden><div id="program-release" hidden><label for="package-version">Version</label><div class="input-row"><select id="package-version"></select><button id="load-release" disabled>Load</button></div></div> + <form id="program-hash" hidden><label for="bundle-hash">Bundle SHA-256</label><div class="input-row"><input id="bundle-hash" maxlength="64" spellcheck="false" autocomplete="off" aria-describedby="bundle-hash-help"><button id="load">Load</button></div></form> + <button id="download-bundle" disabled>Download .arboricx</button></div> + <p id="bundle-hash-help" class="hint" hidden>Load a published executable by its exact 64-character hash.</p><p id="bundle-download-status" class="hint" role="status"></p><p id="release-info" class="hint"></p><p id="package-info" class="hint">Verifying package…</p> + </section> + <section><h3>Scratch examples</h3><p class="hint">Standalone source with contract helpers. Choosing an example replaces the editor contents.</p><div class="demo-picker"><button data-compiler-example="identity">Identity</button><button data-compiler-example="contracts">Checked identity</button><button data-compiler-example="pair">Pair</button><button data-compiler-example="definitions">Definitions</button><button data-compiler-example="input">Line input</button></div></section> + <section><h3>Runtime / compilation</h3><button id="compile" disabled hidden>Compile now</button><p id="compiler-stats" class="hint">Compilation starts after an editing pause.</p><p class="hint">Source stays in this page only. Copy it before leaving; there is no autosave.</p></section> + <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"> <article><h3>Portable Arboricx packages.</h3><p>Arboricx bundles hold named exports and shared tree structure. Content-addressed storage gives trees and modules stable identities. Package a function, move it to another compatible host and apply it there.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/modules.md.html">Explore modules and packages</a></p></article> <article><h3>A web server made of trees.</h3><p>The ecosystem includes HTTP support and an Arboricx registry written in tricu. Tree programs handle requests, build responses and work with the content store through host IO. The same language used for the examples can serve its own packages.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/arboricx/server.tri.html">Explore the registry server</a></p></article> - <article><h3>A compiler that compiles itself.</h3><p>Lexing, parsing, name resolution and lambda elimination are tricu programs. The compiler can compile its own source into Arboricx packages. The Playground runs that packaged compiler so you can follow the journey from source to tree.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/compiler.md.html">Explore the self-hosting compiler</a></p></article> - <article><h3>Programs that inspect programs.</h3><p>Tree Calculus combines computation with structural inspection. Leaf, Stem and Fork represent both functions and data. A program can examine another program's shape, count its nodes or compare its structure. Try the tree microscope in the Playground tab.</p><p><a href="https://treecalcul.us/">Explore Tree Calculus</a></p></article> + <article><h3>A compiler that compiles itself.</h3><p>Lexing, parsing, name resolution and lambda elimination are tricu programs. The compiler can compile its own source into Arboricx packages. The editor runs that packaged compiler so you can follow the journey from source to tree.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/compiler.md.html">Explore the self-hosting compiler</a></p></article> + <article><h3>Programs that inspect programs.</h3><p>Tree Calculus combines computation with structural inspection. Leaf, Stem and Fork represent both functions and data. A program can examine another program's shape, count its nodes or compare its structure. Try the tree microscope in the editor.</p><p><a href="https://treecalcul.us/">Explore Tree Calculus</a></p></article> <article><h3>IO as an interaction tree.</h3><p>A tricu program describes actions and their continuations as trees. The host responds to each request and execution proceeds with that response. The conversation example puts text output, line input and composition into practice.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/io.tri.html">Explore the IO library</a></p></article> <article><h3>Familiar syntax.</h3><p>tricu brings names, lambdas, recursion, pattern matching and familiar literals to Tree Calculus. Write <code>id = (x : x)</code>, compose functions and organize modules. The compiler turns that source into executable trees.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/language.md.html">Read the source language guide</a></p></article> </div></section> - </div> - </section> - <noscript><p class="wrap">Enable JavaScript to use Playground and Export Definition.</p></noscript> - </main> - <footer class="wrap"><p class="attribution"><a href="https://github.com/barry-jay-personal/typed_tree_calculus/blob/main/typed_program_analysis.pdf">Tree Calculus</a> was discovered by <a href="https://github.com/barry-jay-personal/blog">Barry Jay</a>. <a href="https://johannes-bader.com/">Johannes Bader</a> suggested the <a href="https://olydis.medium.com/a-visual-introduction-to-tree-calculus-2f4a34ceffc2">Triage Calculus</a> rules and created <a href="https://treecalcul.us">treecalcul.us</a>.</p><p>Arboricx and tricu are research experiments.</p><p><a href="https://git.eversole.co/James/tricu/file/ext/demo-site/README.md.html">Demo documentation</a></p></footer> +</main><noscript><p class="wrap">Enable JavaScript to use the editor.</p></noscript> + <footer class="wrap" data-inspector-background><p>Copyright 2026 <a href="https://eversole.co">James Eversole</a></p></footer> </body> </html> diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,8 +1,11 @@ +import { printLiteral } from './literal-input.js'; import { backend, backends, graphStats, runtimeStats, type Backend } from './runtime-backends.js'; import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; -import { createTreeExport } from './export-app.js'; -import { activePane } from './tabs.js'; +import { createOutput } from './output-app.js'; +import { setupInspector } from './inspector.js'; +import { isTreeResult } from './tree-result.js'; +import { treeFormat } from './tree-export.js'; import { ActiveClock } from './clock.js'; import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout, type Operation } from './execution.js'; import { sourceText } from './compiler.js'; @@ -18,12 +21,36 @@ function el<T extends HTMLElement = HTMLElement>(id: string): T { const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile'); const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle'); let sourceAvailable = false; +const expressionEditor = el<HTMLTextAreaElement>('expression-editor'); +const guidedInputs = el<HTMLInputElement>('use-guided-inputs'); +let editRevision = 0, composing = false, idleDue = 0; +let idleTimer: ReturnType<typeof setTimeout> | undefined; +function cancelIdleCompile(): void { clearTimeout(idleTimer); idleTimer = undefined; } +function queueCompile(delay = 650): void { + cancelIdleCompile(); + if (composing) return; + idleDue = performance.now() + delay; + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (!busy && draft) compileDraft(); + }, delay); +} +function markPreviousResult(): void { + 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(' '); +} +function expressionInput(): { expression?: string } { + return guidedInputs.checked ? {} : { expression: sourceText(expressionEditor.value) }; +} + 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); let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; -let compiledDraft: CompiledDraft | undefined, compiledSource: string | undefined; +let compiledDraft: CompiledDraft | undefined, compiledSource: string | undefined, compiledRevision: number | undefined; let totalWork = 0; let compilationStats = '', preferredName = 'id'; let compilerTimer: ReturnType<typeof setInterval> | undefined; @@ -40,14 +67,23 @@ const runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElemen let operation: Operation | undefined; const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); const shareButton = el<HTMLButtonElement>('share'); -const treeExport = createTreeExport(message => { - if (draft || !loaded || !selection) return; - if (worker) startExport(message, value => worker?.postMessage(value)); - else newWorker(false, true, message); -}, name => { - exportsInput.value = name; exportsInput.dispatchEvent(new Event('change')); -}, () => finish('Stopped. Export session discarded.', true, 'cancelled')); -const clearTreeExport = treeExport.clear; +const outputUI = createOutput(); +const outputTarget = el<HTMLSelectElement>('output-target'); +const storedOutput = () => outputTarget.value === 'definition'; +const visibleCodec = (value: string): string => value === 'tree' || value === 'list' ? 'tricu' : value; +let resultInterpretation = 'tricu'; +function clearTreeExport(): void { if (storedOutput()) outputUI.clear(); } +function syncOutputTarget(): void { + for (const option of resultCodec.options) option.disabled = storedOutput() && !isTreeResult(option.value); + if (storedOutput() && !isTreeResult(resultCodec.value)) resultCodec.value = 'tricu'; + controls(busy); +} +outputTarget.onchange = () => { + if (busy) return; + if (storedOutput()) resultInterpretation = resultCodec.value; + else resultCodec.value = resultInterpretation; + clearOutput(); syncOutputTarget(); +}; const inputForm = el<HTMLFormElement>('input-form'), lineInput = el<HTMLInputElement>('line-input'); let entries: CatalogueEntry[] = [], selected: CatalogueEntry | undefined; let selection: { hash: BundleHash; localId?: string } | undefined; @@ -66,70 +102,77 @@ function controls(working: boolean): void { versionPicker.disabled = busy; hashInput.disabled = busy; el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value); el<HTMLButtonElement>('load').disabled = busy; - runButton.disabled = draft ? !compiled || (busy && persistentDefinitions()) : busy || !loaded; - runButton.textContent = draft ? 'Run draft' : 'Run published'; - el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource); + runButton.disabled = busy || (storedOutput() ? draft || !loaded : draft ? !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision : !loaded); + runButton.textContent = storedOutput() ? 'Show definition' : 'Evaluate'; + el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); el('draft-run-note').textContent = compiled - ? 'Run draft uses the last successful compilation. Compile draft to apply source edits.' - : 'Draft not compiled. Compile draft, then run it; published programs are not used as a fallback.'; + ? 'Source changed. Evaluation waits for the current revision to compile.' + : 'Waiting for compilation. Published programs are never used as a fallback.'; argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); - el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; + el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; + nextButton.hidden = storedOutput() || !steps.checked; runButton.classList.toggle('draft', draft); runButton.classList.toggle('primary', !draft || compiled); - compileButton.classList.toggle('primary', !compiled); - compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource); compileButton.hidden = !draft; restoreButton.hidden = !draft; - sourceEditor.disabled = busy; sourceEditor.readOnly = originalSource === undefined && !draft; + compileButton.classList.remove('primary'); + compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); compileButton.hidden = !draft; restoreButton.hidden = !draft; + sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); - el('source-summary').textContent = editableSource ? 'Source and editing' : sourceAvailable ? 'Source (read-only)' : 'Package details'; - el('source-title').textContent = editableSource ? 'Read and edit source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; + el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; el('source-availability').hidden = editableSource || !loaded; el('source-availability').textContent = sourceAvailable - ? 'Source is shown for reference only. This package does not support editing or local compilation.' - : 'This is a compiled package. Editable source is not included; you can run or export its stored definitions.'; + ? 'Source is shown for reference only; the published implementation cannot be edited here. Evaluate expressions against its exports below.' + : 'This is a compiled package. Editable source is not included; you can evaluate expressions against its exports or export its stored definitions.'; restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || downloadHash() === undefined; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; - const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource); + const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision); el('authorship').classList.toggle('modified', sourceModified); sourceEditor.classList.toggle('modified', sourceModified); - el('export-draft-note').hidden = !draft; + 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 && (worker || compiled)); - el<HTMLButtonElement>('stop-compile').disabled = stopButton.disabled; - el('stop-compile').hidden = !draft; + stopButton.disabled = !busy && !draft; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); - mode.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy; - shareButton.disabled = busy || draft || !loaded; - treeExport.controls(busy, loaded && !draft); + expressionEditor.disabled = busy && operation !== 'compiling'; guidedInputs.disabled = busy; + el('expression-inputs').hidden = guidedInputs.checked; el('guided-inputs').hidden = !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); } function dispose(preserveDraft = false): void { - stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; - if (!preserveDraft) { compiledDraft = undefined; compiledSource = undefined; } + cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; + if (!preserveDraft) { compiledDraft = undefined; compiledSource = undefined; compiledRevision = undefined; } compiled = compiledDraft !== undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); } function clearOutput(): void { + el('result-stale').hidden = true; el('compile-diagnostic').hidden = true; el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = ''; - el('result-inspection').hidden = true; - el('graph-result').textContent = ''; el('graph-result').hidden = true; + outputUI.clear(); el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = ''; } function finish(message: string, error = false, reason: 'failure' | 'timeout' | 'cancelled' = 'failure'): void { if (error && reason === 'failure') message = operationFailure(operation, message); + if (operation === 'compiling' && reason === 'failure') { + dispose(); el('status').textContent = 'Compilation failed'; + el('compiler-stats').textContent = 'Compilation failed; runtime discarded.'; + el('compile-diagnostic').textContent = message; el('compile-diagnostic').hidden = false; + return; + } if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; - if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message); + 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'; - el('result').textContent = message; el('result').className = error ? 'error' : ''; + if (!downloadOnly) { el('result').textContent = message; el('result').className = error ? 'error' : ''; } for (const row of traceRows.values()) row.classList.remove('pending'); } function setPreset(entry: CatalogueEntry, name = entry.export): void { const preset = entry.presets?.[name] ?? entry; argumentsUI.labels(demoInputLabels(entry.id, name)); - argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = preset.output; mode.value = preset.mode; + argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = visibleCodec(preset.output); mode.value = preset.mode; presetExpression(name); } function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: (message: ClientMessage) => void): void { - operation = 'exporting'; treeExport.begin(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock(); + operation = 'exporting'; clearOutput(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock(); el('status').textContent = 'Exporting definition'; send(message); } function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoad?: Extract<ClientMessage, { type: 'export' }>): void { @@ -153,19 +196,22 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa break; case 'loaded': { currentVerified = true; loaded = true; + el('compiler-stats').textContent = 'Published package · no local compilation.'; const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export); exportsInput.replaceChildren(); for (const item of data.exports) { const option = document.createElement('option'); option.value = item.name; option.textContent = item.name; exportsInput.append(option); } if (oldExport && data.exports.some(e => e.name === oldExport)) exportsInput.value = oldExport; - preferredExport = undefined; treeExport.selection(exportsInput); + preferredExport = undefined; el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`; if (!preserveInputs && !automaticRun && !exportAfterLoad) { originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined; el('source-notes').hidden = originalSource !== undefined; } + if (!preserveInputs && !automaticRun && !exportAfterLoad) presetExpression(); + if (storedOutput() && !isTreeResult(resultCodec.value)) resultCodec.value = 'tricu'; 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.`; @@ -176,11 +222,12 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa } case 'exported': { pauseClock(); - if (!treeExport.receive(data, selection?.hash, exportsInput.value)) break; + 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}`); operation = undefined; controls(false); el('status').textContent = 'Ready'; break; } - case 'export-error': pauseClock(); treeExport.fail(operationFailure('exporting', data.message)); operation = undefined; controls(false); el('status').textContent = 'Ready'; break; + case 'export-error': finish(data.message, true); break; case 'trace': { el('trace-empty').hidden = true; const event = data.event; if (data.phase === 'before') { @@ -195,11 +242,10 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa } case 'output': el('terminal').textContent += data.text; el('terminal').scrollTop = el('terminal').scrollHeight; break; case 'done': { - const { result, stats } = data; - if (result.type === 'tree' || result.type === 'list') { - el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(result.value, null, 2); - finish(`Returned ${result.type}: tree graph · ${runtimeStats(stats)}`); - } else finish(`Returned ${result.type}: ${JSON.stringify(result.value)}`); + if (persistentDefinitions()) idleDraft(); else dispose(); + el('status').textContent = 'Finished'; + outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`); + el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`; break; } case 'error': if (!currentVerified) loaded = false; finish(data.message, true); break; @@ -218,8 +264,6 @@ function select(entry: CatalogueEntry, release?: Release): void { sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; - el<HTMLDetailsElement>('source-section').open = true; - el<HTMLDetailsElement>('try-program').open = true; 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); @@ -233,7 +277,7 @@ function invalidateProgram(): void { sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; - exportsInput.replaceChildren(); treeExport.selection(exportsInput); + exportsInput.replaceChildren(); sourceEditor.hidden = true; el('source-notes').hidden = false; el('source').textContent = 'Load the selected program to inspect its source or package notes.'; el('package-info').textContent = 'No bundle loaded.'; showRelease(); @@ -241,27 +285,33 @@ function invalidateProgram(): void { } function loadBundle(release?: Release): void { const value = release?.hash ?? hashInput.value; - el<HTMLDetailsElement>('source-section').open = true; - el<HTMLDetailsElement>('try-program').open = true; invalidateProgram(); chooseProgram(release ? releaseProgram(release) : 'hash', release?.version); hashInput.value = value; try { const hash = bundleHash(value); selection = { hash }; - argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tree'; mode.value = 'pure'; + argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure'; showRelease(release); el('source').textContent = 'Verifying registry package…'; newWorker(false); } catch (error) { operation = 'loading'; finish(error instanceof Error ? error.message : String(error), true); } } el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); }; hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; -exportsInput.onchange = () => { preferredName = exportsInput.value; clearTreeExport(); treeExport.selection(exportsInput); if (!scratch && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); }; +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 (storedOutput()) { + if (draft || !loaded || !selection) return; + const request: Extract<ClientMessage, { type: 'export' }> = { type: 'export', export: exportsInput.value, format: treeFormat(resultCodec.value) }; + if (worker) startExport(request, value => worker?.postMessage(value)); + else newWorker(false, true, request); + return; + } if (draft) { runDraft(); return; } if (!loaded || !selection) return; operation = 'running'; clearOutput(); try { - const args = argumentsUI.read(), output: OutputType = outputType(resultCodec.value); - savedRun = { type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; + const args = guidedInputs.checked ? argumentsUI.read() : [], output: OutputType = outputType(resultCodec.value); + savedRun = { type: 'run', ...expressionInput(), export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; if (worker) { controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage(savedRun); } else newWorker(true); } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } @@ -278,16 +328,14 @@ 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'); -el<HTMLButtonElement>('stop-compile').onclick = stopButton.onclick; -el('try-program-link').onclick = () => { el<HTMLDetailsElement>('try-program').open = true; }; -el<HTMLButtonElement>('reset').onclick = () => { keepDraft(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +el<HTMLButtonElement>('reset').onclick = () => { if (busy) return; 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 }); if (selection.localId) fragment.set('demo', selection.localId); url.hash = fragment.toString(); - try { await navigator.clipboard.writeText(url.href); el('result').textContent = 'Copied a link to this package and export.'; } - catch { el('result').textContent = url.href; } + try { await navigator.clipboard.writeText(url.href); el('bundle-download-status').textContent = 'Copied a link to this package and export.'; } + catch { el('bundle-download-status').textContent = url.href; } }; window.addEventListener('pagehide', () => dispose()); // Associate an example with its published name only through the catalogue's exact pin. @@ -381,22 +429,37 @@ downloadButton.onclick = () => { document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`; dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; - el('bundle-download-status').textContent = message; el('result').textContent = message; el('result').className = ''; + el('bundle-download-status').textContent = message; } else if (data.type === 'error') finish(data.message, true); }; current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); }; current.postMessage({ type: 'load', hash }); }; -mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; }; +resultCodec.onchange = markPreviousResult; +argsInput.addEventListener('input', markPreviousResult); +el('argument-fields').addEventListener('input', markPreviousResult); +mode.onchange = () => { markPreviousResult(); el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; }; +expressionEditor.oninput = markPreviousResult; +guidedInputs.onchange = () => { + if (!guidedInputs.checked) { + try { presetExpression(); } + catch (error) { guidedInputs.checked = true; outputUI.clear(); el('result').textContent = String(error); } + } + markPreviousResult(); controls(busy); +}; +sourceEditor.addEventListener('compositionstart', () => { composing = true; cancelIdleCompile(); }); +sourceEditor.addEventListener('compositionend', () => { composing = false; if (draft) queueCompile(); }); sourceEditor.oninput = () => { - el<HTMLDetailsElement>('source-section').open = true; + if (busy && operation !== 'compiling') return; + editRevision++; markPreviousResult(); el('compile-diagnostic').hidden = true; + if (operation === 'compiling') { draft = true; controls(true); queueCompile(); return; } preferredName = exportsInput.value || preferredName; - keepDraft(); clearOutput(); clearTreeExport(); + keepDraft(); clearTreeExport(); if (!compiled) exportsInput.replaceChildren(); draft = compiled || scratch || sourceEditor.value !== originalSource; controls(false); if (!draft && selection) { preferredExport = preferredName; newWorker(false, true); } - else el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; + else { el('status').textContent = 'Changes pending'; queueCompile(); } }; restoreButton.onclick = () => { if (originalSource === undefined) return; @@ -404,27 +467,34 @@ restoreButton.onclick = () => { }; compileButton.onclick = () => compileDraft(); function compileDraft(): void { - if (!draft) return; - dispose(true); operation = 'compiling'; clearOutput(); clearTreeExport(); + if (!draft || busy) return; + const snapshot = { revision: editRevision, source: sourceEditor.value }; + dispose(true); operation = 'compiling'; markPreviousResult(); clearTreeExport(); + el('compile-diagnostic').hidden = true; try { sourceText(sourceEditor.value); - const current = draftWorker(); + const current = draftWorker(snapshot); controls(true); deadline.reset(Infinity); clock(); el('status').textContent = 'Compiling'; el('compiler-stats').textContent = 'Loading pinned compiler and demo environment…'; const started = performance.now(); const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; }; activity(); compilerTimer = setInterval(activity, 1000); - current.postMessage({ type: 'compile', source: sourceEditor.value, + current.postMessage({ type: 'compile', source: snapshot.source, ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } } -function draftWorker(): Worker { +function draftWorker(snapshot?: { revision: number; source: string }): Worker { const current = new Worker(workerURL(true), { type: 'module' }); worker = current; + const discardStale = (): boolean => { + 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 => { if (worker !== current) return; + if ((data.type === 'compiled' || data.type === 'error') && discardStale()) return; switch (data.type) { case 'compiled': - stopCompilerActivity(); pauseClock(); operation = undefined; compiledDraft = data.draft; compiledSource = sourceEditor.value; compiled = true; totalWork = graphStats(data.stats) ? data.stats.work : 0; + 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'); option.value = name; option.textContent = name; exportsInput.append(option); } if (data.names.includes(preferredName)) exportsInput.value = preferredName; @@ -443,19 +513,18 @@ function draftWorker(): Worker { case 'done': if (graphStats(data.stats)) totalWork += data.stats.work; if (persistentDefinitions()) idleDraft(); else dispose(true); el('status').textContent = 'Finished'; - if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } - else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; + 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)}`; break; case 'error': if (persistentDefinitions() && operation === 'running' && !data.fatal) { - idleDraft(); el('status').textContent = 'Stopped'; + idleDraft(); outputUI.clear(); el('status').textContent = 'Stopped'; el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; } else finish(data.message, true); break; } }; - current.onerror = event => { if (worker === current) { event.preventDefault(); finish(event.message || 'Compiler Worker failed.', true); } }; + 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 @@ -469,23 +538,25 @@ function keepDraft(): void { else dispose(true); } function runDraft(): void { - if (!compiled) return; + if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) return; try { - const args = argumentsUI.read(), output = outputType(resultCodec.value); + const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); + const expression = expressionInput(); if (!persistentDefinitions()) dispose(true); else 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 }); - current.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); + 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'; keepDraft(); } } backendPicker.onchange = () => { - selectedBackend = backend(backendPicker.value); dispose(); clearOutput(); - if (draft) { controls(false); el('status').textContent = 'Backend changed · compile draft to run'; } + selectedBackend = backend(backendPicker.value); editRevision++; dispose(); markPreviousResult(); + if (draft) { controls(false); el('status').textContent = 'Changes pending'; queueCompile(0); } else if (selection) newWorker(false, true); }; const examples = { @@ -498,14 +569,13 @@ 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(''); - el<HTMLDetailsElement>('source-section').open = true; keepDraft(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; if (!compiled) exportsInput.replaceChildren(); - preferredName = example.name; + editRevision++; preferredName = example.name; sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source; - argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); resultCodec.value = example.output; mode.value = example.mode; - controls(false); el('source-label').textContent = 'Scratch program · contract helpers'; - el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; + argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); resultCodec.value = visibleCodec(example.output); mode.value = example.mode; presetExpression(example.name); + controls(false); el('package-info').textContent = 'Scratch program · no published bundle selected.'; el('release-info').textContent = ''; el('source-label').textContent = 'Scratch program · contract helpers'; + el('status').textContent = 'Changes pending'; queueCompile(); }; function loadFragment(): boolean { const fragment = new URLSearchParams(location.hash.slice(1)); @@ -518,17 +588,16 @@ function loadFragment(): boolean { chooseProgram('hash'); showRelease(); draft = false; scratch = false; originalSource = undefined; sourceAvailable = false; dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined; - argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tree'; mode.value = 'pure'; + argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure'; selection = { hash }; hashInput.value = hash; newWorker(false); } return true; } window.addEventListener('hashchange', () => { - if (!entries.length || !packagePane()) return; + if (!entries.length) return; try { if (new URLSearchParams(location.hash.slice(1)).has('bundle')) loadFragment(); } catch (error) { loaded = false; finish(error instanceof Error ? error.message : String(error), true); } }); -function packagePane(): boolean { return activePane() === 'playground' || activePane() === 'export-definition'; } let initialized = false; async function initializePackages(): Promise<void> { if (initialized) return; @@ -550,20 +619,9 @@ async function initializePackages(): Promise<void> { const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576); releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown); populatePrograms(); - if (!packagePane() || selection || draft) { operation = undefined; return; } + if (selection || draft) { operation = undefined; return; } if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); selectExample(initial); } } catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); } } -window.addEventListener('pane-change', () => { - // Moving between panels cancels active work, but retains verified selection and - // completed exports. Move the single package chooser rather than mirror inputs. - if (worker) { - if (busy) finish('Stopped. Start a new operation in the selected tab.', true, 'cancelled'); - else if (!draft) { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; } - } - if (packagePane()) { - document.querySelector(`#${activePane()} [data-package-slot]`)!.append(el('package-selection')); - if (!initialized) void initializePackages(); - else if (!draft && entries.length && !loaded && selection) newWorker(false); - } -}); +setupInspector(); +void initializePackages(); diff --git a/ext/demo-site/src/codecs.ts b/ext/demo-site/src/codecs.ts @@ -1,10 +1,12 @@ +import { treeResult, type TreeResultFormat } from './tree-result.js'; +import type { TreeFile } from './tree-export.js'; import { Graph, type NodeId, type GraphPreview } from './graph.js'; export type Literal = { type: 'string' | 'number' | 'tree'; value: string } | { type: 'bool'; value: boolean } | { type: 'bytes'; value: number[] } | { type: 'list'; value: Literal[] }; -export type OutputType = 'tree' | 'string' | 'number' | 'bool' | 'bytes' | 'list' | 'record'; -export type ValueView = { type: 'tree'; value: GraphPreview } - | { type: Exclude<OutputType, 'tree'>; value: string | boolean | number[] | GraphPreview[] | Record<string, string | number | boolean> }; +export type OutputType = TreeResultFormat | 'tree' | 'string' | 'number' | 'bool' | 'bytes' | 'list' | 'record'; +export type ValueView = { type: TreeResultFormat; value: string; file: TreeFile } | { type: 'tree'; value: GraphPreview } + | { type: Exclude<OutputType, 'tree' | TreeResultFormat>; value: string | boolean | number[] | GraphPreview[] | Record<string, string | number | boolean> }; export const TEXT_LIMIT = 4096; export function natural(graph: Graph, value: bigint): NodeId { if (value < 0n || value.toString(2).length > 4096) throw new Error('Natural number limit exceeded'); @@ -106,11 +108,12 @@ export function makeLiteral(graph: Graph, value: Literal): NodeId { } } export function outputType(input: unknown): OutputType { - if (!['tree', 'string', 'number', 'bool', 'bytes', 'list', 'record'].includes(String(input))) throw new Error('Unsupported output codec'); + if (!['tree', 'string', 'number', 'bool', 'bytes', 'list', 'record', 'tricu', 'triangle', 'dag', 'ternary'].includes(String(input))) throw new Error('Unsupported output codec'); return input as OutputType; } export function view(graph: Graph, handle: NodeId, type: OutputType): ValueView { switch (type) { + case 'tricu': case 'triangle': case 'dag': case 'ternary': return treeResult(graph.snapshot(handle), type); case 'record': return { type, value: readRecord(graph, handle) }; case 'tree': return { type, value: graph.preview(handle) }; case 'string': return { type, value: readText(graph, handle) }; diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts @@ -5,7 +5,7 @@ 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; output: OutputType; mode: 'pure' | 'io'; step: boolean } + | { 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/compiler-worker.ts b/ext/demo-site/src/compiler-worker.ts @@ -1,3 +1,4 @@ +import { compileExpression } from './expression.js'; import { resumeValue } from './io.js'; import { environmentProfile } from './packages.js'; import { BROWSER_BUDGET, collectionPolicy } from './execution.js'; @@ -6,7 +7,7 @@ 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, compiledDefinitions } from './compiler.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 }; @@ -58,11 +59,11 @@ async function compile(source: unknown, profileName?: string): Promise<void> { 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 = exports.get(message.export); + 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 = graph.apply(fn, args); + 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 }); }, @@ -96,6 +97,7 @@ scope.onmessage = ({ data }) => { } 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); } diff --git a/ext/demo-site/src/export-app.ts b/ext/demo-site/src/export-app.ts @@ -1,76 +0,0 @@ -import { treeFormat } from './tree-export.js'; -import type { BundleHash } from './bundle.js'; -import type { ClientMessage, WorkerMessage } from './protocol.js'; -type Exported = Extract<WorkerMessage, { type: 'exported' }>; -type Request = Extract<ClientMessage, { type: 'export' }>; -function el<T extends HTMLElement = HTMLElement>(id: string): T { - const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); return element as T; -} -// Presentation owns its output and invalidation; the package controller owns the -// shared selection and worker. No arguments or execution settings enter exports. -export function createTreeExport(request: (message: Request) => void, select: (name: string) => void, stop: () => void) { - const definition = el<HTMLSelectElement>('tree-definition'), format = el<HTMLSelectElement>('tree-format'); - const button = el<HTMLButtonElement>('export-tree'), cancel = el<HTMLButtonElement>('stop-tree'); - const copy = el<HTMLButtonElement>('copy-tree'), download = el<HTMLButtonElement>('download-tree'); - const preview = el('tree-preview'), status = el('tree-provenance'), empty = el('tree-empty'); - let exported: Exported | undefined, pending = false; - const descriptions = { - ternary: 'A single line: 0 is Leaf, 1 introduces a Stem, and 2 introduces a Fork. Shared branches are repeated.', - tricu: 'A fully parenthesized tree expression using t. This is generated tree structure, not the original source.', - triangle: 'A fully parenthesized tree expression using △. Shared branches are repeated.', - dag: 'Shared △ applications with a fixed export named tree. Choose DAG when an expanded tree would be too large.', - }; - function clear(): void { - exported = undefined; pending = false; preview.textContent = ''; preview.hidden = true; empty.hidden = false; - status.textContent = 'Export the selected stored definition.'; status.classList.remove('error'); - copy.disabled = true; download.disabled = true; - } - function help(): void { el('tree-format-help').textContent = descriptions[treeFormat(format.value)]; } - function begin(): void { clear(); pending = true; status.textContent = 'Preparing definition export…'; } - function fail(message: string): void { clear(); status.textContent = message; status.classList.add('error'); } - format.onchange = () => { clear(); help(); }; - definition.onchange = () => { clear(); select(definition.value); }; - button.onclick = () => { - try { begin(); request({ type: 'export', export: definition.value, format: treeFormat(format.value) }); } - catch (error) { fail(error instanceof Error ? error.message : String(error)); } - }; - cancel.onclick = stop; - copy.onclick = async () => { - const value = exported; if (!value) return; - try { - await navigator.clipboard.writeText(value.file.text); - if (exported === value) status.textContent += ' · Copied complete tree.'; - } catch { if (exported === value) status.textContent += ' · Clipboard unavailable. Use Download tree.'; } - }; - download.onclick = () => { - if (!exported) return; - const url = URL.createObjectURL(new Blob([exported.file.text], { type: 'text/plain;charset=utf-8' })); - const anchor = document.createElement('a'); anchor.href = url; - const name = exported.export.replace(/[^a-zA-Z0-9_-]/gu, '-').slice(0, 64) || 'tree'; - anchor.download = `${name}-${exported.hash.slice(0, 12)}.${exported.file.extension}`; - document.body.append(anchor); anchor.click(); anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - }; - help(); - return { - clear, begin, fail, - get pending(): boolean { return pending; }, - selection(input: HTMLSelectElement): void { - definition.replaceChildren(...[...input.options].map(option => option.cloneNode(true))); - definition.value = input.value; - }, - controls(busy: boolean, loaded: boolean): void { - button.disabled = format.disabled = definition.disabled = busy || !loaded; - cancel.disabled = !busy; copy.disabled = download.disabled = busy || !exported; - }, - receive(data: Exported, hash: BundleHash | undefined, name: string): boolean { - if (data.hash !== hash || data.export !== name || data.format !== format.value) return false; - exported = data; pending = false; - preview.textContent = data.file.text.slice(0, 16_384); preview.hidden = false; empty.hidden = true; - status.classList.remove('error'); - status.textContent = `SHA-256 ${data.hash} · definition ${data.export} · ${data.file.bytes.toLocaleString()} UTF-8 bytes · ${data.file.storedNodes.toLocaleString()} stored nodes` + - (preview.textContent.length < data.file.text.length ? ' · Preview: first 16,384 characters. Copy and Download contain the complete tree.' : ' · Complete tree.'); - return true; - }, - }; -} diff --git a/ext/demo-site/src/expression.ts b/ext/demo-site/src/expression.ts @@ -0,0 +1,34 @@ +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'; + +// 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: unknown, names: Iterable<string>): { name: string; source: string } { + const expression = sourceText(input), occupied = new Set(names); + let i = 0, name: string; + 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+)/, + (_match, label: string, line: string, column: string) => `${label} at expression ${Math.max(1, Number(line) - 1)}:${Math.max(1, Number(column) - 2)}`)); +} diff --git a/ext/demo-site/src/graph.ts b/ext/demo-site/src/graph.ts @@ -241,10 +241,19 @@ export class Graph { } preview(handle: NodeId, limit = 64): GraphPreview { if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid preview budget'); + return this.capture(handle, limit); + } + snapshot(handle: NodeId, limit = 100_000): GraphPreview { + if (!Number.isInteger(limit) || limit < 1 || limit > 100_000) throw new Error('Invalid snapshot budget'); + return this.capture(handle, limit); + } + private capture(handle: NodeId, limit: number): GraphPreview { const root = this.force(handle), seen = new Set<NodeId>(), pending = [root]; const nodes: GraphPreview['nodes'] = [], aliases: GraphPreview['aliases'] = []; const forwarded = new Set<NodeId>(); - while (pending.length && nodes.length < limit) { + while (pending.length) { + if (seen.has(pending.at(-1)!) || forwarded.has(pending.at(-1)!)) { pending.pop(); continue; } + if (nodes.length >= limit) break; const handle = pending.pop()!, { id, node } = this.observe(handle); if (id !== handle && !forwarded.has(handle)) { forwarded.add(handle); aliases.push({ from: handle, to: id }); } if (seen.has(id)) continue; diff --git a/ext/demo-site/src/haskell-session.ts b/ext/demo-site/src/haskell-session.ts @@ -1,3 +1,6 @@ +import { isTreeResult, treeResult } from './tree-result.js'; +import type { GraphPreview } from './graph.js'; +import { expressionUnit, expressionDiagnostic } from './expression.js'; // Backend-private protocol mapping. No WASM pointer, Haskell heap checkpoint or // fabricated graph statistic crosses into the shared site protocol. import { compilerDescriptor } from './compiler.js'; @@ -30,6 +33,7 @@ export class HaskellSession { } catch (error) { session.dispose(); throw error; } } private compilerHash: string | null = null; + private names: string[] = []; get disposed(): boolean { return this.client.disposed; } private async request(message: object): Promise<Reply> { const reply = await this.client.request(message, null); @@ -37,7 +41,7 @@ export class HaskellSession { return reply; } private async json<T>(message: object): Promise<T> { return JSON.parse((await this.request(message)).text) as T; } - async load(bytes: Uint8Array, hash: BundleHash): Promise<void> { await this.request({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } + async load(bytes: Uint8Array, hash: BundleHash): Promise<void> { this.names = await this.json<string[]>({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } async compile(source: string, profileName?: string): Promise<string[]> { let environment: { bytes: ArrayBuffer; hash: BundleHash; names: string[] } | null | undefined; if (profileName !== undefined) { @@ -50,13 +54,36 @@ export class HaskellSession { } : null; } const reply = await this.request({ type: 'compile', source, ...(environment === undefined ? {} : { environment }) }); - return reply.text.trimEnd().split('\n').filter(Boolean); + this.names = reply.text.trimEnd().split('\n').filter(Boolean); + return this.names; } async run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView> { - if (mode === 'pure') return this.json<ValueView>({ type: 'evaluate', name, arguments: args, output }); + return this.execute({ type: mode === 'pure' ? 'evaluate' : 'io-start', name, arguments: args, output }, output, mode, host); + } + async runExpression(source: string, output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView> { + const unit = expressionUnit(source, this.names); + if (!this.compilerHash) { + const bytes = await fetchBytes(new URL('../compiler.json', import.meta.url), 4096); + const expected = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown).hash; + const ready = await this.request({ type: 'compiler-init' }); + if (ready.compilerHash !== expected) { this.dispose(); throw new Error('Runtime compiler pin mismatch'); } + this.compilerHash = expected; + } + try { return await this.execute({ type: 'expression', ...unit, output, mode }, output, mode, host); } + catch (error) { throw expressionDiagnostic(error); } + } + private async execute(request: object, output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView> { + const displayed = async (message: object): Promise<ValueView> => { + if (isTreeResult(output)) { + const snapshot = await this.json<{ value: GraphPreview }>(message); + return treeResult(snapshot.value, output); + } + return this.json<ValueView>(message); + }; + if (mode === 'pure') return displayed(request); type Event = { kind: string; token: number; step: number; name: string; detail: string; depth: number; value: string; text: string }; try { - let event = await this.json<Event>({ type: 'io-start', name, arguments: args }); + let event = await this.json<Event>(request); while (event.kind !== 'done') { let value = ''; switch (event.kind) { @@ -69,7 +96,7 @@ export class HaskellSession { host.running(); event = await this.json<Event>({ type: 'io-resume', token: event.token, value }); } - return await this.json<ValueView>({ type: 'io-view', output }); + return await displayed({ type: 'io-view', output }); } finally { // Release result/continuations even when decoding or a host hook fails. // A poisoned instance cannot be recovered by cancelling the interaction. diff --git a/ext/demo-site/src/inspector.ts b/ext/demo-site/src/inspector.ts @@ -0,0 +1,32 @@ +// A docked inspector on wide screens, an accessible modal sheet on phones. +export function setupInspector(): void { + const panel = document.getElementById('settings-panel')!, toggle = document.getElementById('settings-toggle')!; + const close = document.getElementById('settings-close')!, backdrop = document.getElementById('settings-backdrop')!; + const body = document.getElementById('editor-body')!, mobile = matchMedia('(max-width:740px)'); + const background = [...document.querySelectorAll<HTMLElement>('[data-inspector-background]')]; + let open = false; + function layout(): void { + panel.hidden = !open; backdrop.hidden = !open || !mobile.matches; + body.classList.toggle('settings-open', open); + toggle.setAttribute('aria-expanded', String(open)); + panel.setAttribute('role', mobile.matches ? 'dialog' : 'region'); + if (mobile.matches && open) panel.setAttribute('aria-modal', 'true'); else panel.removeAttribute('aria-modal'); + for (const element of background) element.inert = open && mobile.matches; + document.body.classList.toggle('inspector-modal', open && mobile.matches); + if (open && mobile.matches && !panel.contains(document.activeElement)) close.focus(); + } + function hide(): void { open = false; layout(); toggle.focus(); } + toggle.onclick = () => { if (open) hide(); else { open = true; layout(); close.focus(); } }; + close.onclick = backdrop.onclick = hide; + mobile.addEventListener('change', layout); + panel.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); hide(); } + if (event.key !== 'Tab' || !mobile.matches) return; + const items = [...panel.querySelectorAll<HTMLElement>('button,input,select,textarea,a[href],summary,[tabindex="0"]')] + .filter(e => !e.hasAttribute('disabled') && e.getClientRects().length > 0); + const first = items[0], last = items.at(-1); + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }); + layout(); +} diff --git a/ext/demo-site/src/output-app.ts b/ext/demo-site/src/output-app.ts @@ -0,0 +1,39 @@ +import type { ValueView } from './codecs.js'; +import type { TreeFile } from './tree-export.js'; +// One output surface. The saved payload carries its own provenance/format; +// changing controls cannot relabel an earlier result or change its download. +export function createOutput() { + const text = document.getElementById('result')!; + const provenance = document.getElementById('output-provenance')!; + const copy = document.getElementById('copy-output') as HTMLButtonElement; + const download = document.getElementById('download-output') as HTMLButtonElement; + let saved: { text: string; filename: string } | undefined, busy = false; + const controls = (working: boolean) => { busy = working; copy.disabled = download.disabled = busy || !saved; }; + function clear(): void { saved = undefined; text.textContent = ''; text.className = ''; provenance.textContent = 'Evaluate an expression, or show a stored definition. Nothing runs automatically.'; controls(busy); } + function show(value: string, filename: string, description: string): void { + saved = { text: value, filename }; text.textContent = value.slice(0, 16_384); text.className = ''; + provenance.textContent = description + (value.length > 16_384 ? ' · Preview: first 16,384 characters. Copy and Download include the complete output.' : ' · Complete output.'); + controls(busy); + } + function file(value: TreeFile, name: string, description: string): void { + show(value.text, `${name.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64)}.${value.extension}`, `${description} · ${value.bytes.toLocaleString()} UTF-8 bytes`); + } + function result(value: ValueView, description: string): void { + if ('file' in value) { file(value.file, 'result', `${description} · ${value.type}`); return; } + if (value.type === 'tree' || value.type === 'list') throw new Error('Select tricu source or DAG to inspect tree results'); + const content = typeof value.value === 'string' ? value.value : JSON.stringify(value.value, null, 2); + show(content, value.type === 'record' || value.type === 'bytes' ? 'result.json' : 'result.txt', `${description} · ${value.type}`); + } + copy.onclick = async () => { + const value = saved; if (!value) return; + try { await navigator.clipboard.writeText(value.text); if (saved === value) provenance.textContent += ' · Copied.'; } + catch { if (saved === value) provenance.textContent += ' · Clipboard unavailable; use Download.'; } + }; + download.onclick = () => { + if (!saved) return; + const url = URL.createObjectURL(new Blob([saved.text], { type: 'text/plain;charset=utf-8' })); + const a = document.createElement('a'); a.href = url; a.download = saved.filename; document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + clear(); return { clear, controls, file, result }; +} diff --git a/ext/demo-site/src/protocol.ts b/ext/demo-site/src/protocol.ts @@ -11,7 +11,7 @@ export interface CatalogueEntry { presets?: Record<string, { arguments: unknown[]; output: OutputType; mode: 'pure' | 'io' }>; } export type ClientMessage = { type: 'load'; hash: BundleHash; localId?: string } - | { type: 'run'; export: string; arguments: unknown; output: OutputType; mode: 'pure' | 'io'; step: boolean } + | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'download' } | { type: 'export'; export: string; format: TreeFormat } | { type: 'resume'; id: RequestId; value?: string }; diff --git a/ext/demo-site/src/runtime-session.ts b/ext/demo-site/src/runtime-session.ts @@ -11,6 +11,7 @@ export interface RuntimeSession { load(bytes: Uint8Array, hash: BundleHash): Promise<void>; compile(source: string, profileName?: string): Promise<string[]>; run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; + runExpression(source: string, output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; stats(): RuntimeStats; dispose(): void; } diff --git a/ext/demo-site/src/tabs.ts b/ext/demo-site/src/tabs.ts @@ -1,31 +0,0 @@ -type Pane = 'how' | 'playground' | 'export-definition'; -const tabs = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')]; -export function activePane(): Pane { - return (tabs.find(tab => tab.getAttribute('aria-selected') === 'true')?.dataset['pane'] ?? 'how') as Pane; -} -function activate(pane: Pane, focus = false): void { - const changed = activePane() !== pane; - for (const tab of tabs) { - const selected = tab.dataset['pane'] === pane; - tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; - const panel = document.getElementById(tab.getAttribute('aria-controls')!)!; - panel.classList.toggle('is-active', selected); panel.inert = !selected; - panel.setAttribute('aria-hidden', String(!selected)); - if (selected && focus) tab.focus(); - } - if (changed) window.dispatchEvent(new CustomEvent('pane-change', { detail: pane })); -} -for (const [index, tab] of tabs.entries()) { - tab.onclick = () => activate(tab.dataset['pane'] as Pane); - tab.onkeydown = event => { - const target = event.key === 'ArrowRight' ? (index + 1) % tabs.length - : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length - : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : undefined; - if (target !== undefined) { event.preventDefault(); activate(tabs[target]!.dataset['pane'] as Pane, true); } - }; -} -for (const link of document.querySelectorAll<HTMLAnchorElement>('a[data-open-pane]')) link.onclick = () => { - activate(link.dataset['openPane'] as Pane); -}; -// Selection links are metadata, not permission to enter an execution pane. -activate('how'); diff --git a/ext/demo-site/src/tree-result.ts b/ext/demo-site/src/tree-result.ts @@ -0,0 +1,44 @@ +import type { GraphPreview } from './graph.js'; +import type { Bundle } from './bundle.js'; +import { exportTree, type TreeFormat, type TreeFile } from './tree-export.js'; +export const RESULT_NODE_LIMIT = 100_000; +export type TreeResultFormat = 'tricu' | 'triangle' | 'dag' | 'ternary'; +export function isTreeResult(value: string): value is TreeResultFormat { + return ['tricu', 'triangle', 'dag', 'ternary'].includes(value); +} +// Backend snapshots describe physical sharing, not structural hash-consing. +// Reject incomplete/cyclic observations instead of exporting a plausible prefix. +export function snapshotBundle(snapshot: GraphPreview): Bundle { + if (snapshot.truncated) throw new Error('Result snapshot exceeds 100,000 nodes; refusing a partial export'); + const aliases = new Map<number, number>(snapshot.aliases.map(a => [a.from, a.to])); + const byId = new Map<number, GraphPreview['nodes'][number]>(snapshot.nodes.map(n => [n.id, n])); + const resolve = (raw: number): number => { + let id = raw; const seen = new Set<number>(); + while (aliases.has(id)) { if (seen.has(id)) throw new Error('Snapshot alias cycle'); seen.add(id); id = aliases.get(id)!; } + return id; + }; + const nodes: Bundle['nodes'] = [], mapped = new Map<number, number>(), active = new Set<number>(); + const root = resolve(snapshot.root), stack: Array<[number, boolean]> = [[root, false]]; + while (stack.length) { + const [raw, leave] = stack.pop()!, id = resolve(raw); + if (mapped.has(id)) continue; + const node = byId.get(id); if (!node) throw new Error('Incomplete result snapshot'); + const arity = { leaf: 0, stem: 1, fork: 2 }[node.kind]; + if (arity === undefined || node.children.length !== arity) throw new Error('Invalid snapshot constructor'); + if (!leave) { + if (active.has(id)) throw new Error('Cyclic result cannot be exported as a DAG'); + active.add(id); stack.push([id, true]); + for (const child of [...node.children].reverse()) stack.push([child, false]); + } else { + active.delete(id); const cs = node.children.map(c => mapped.get(resolve(c))!); + if (cs.some(c => c === undefined)) throw new Error('Invalid result snapshot order'); + mapped.set(id, nodes.length); + nodes.push(node.kind === 'leaf' ? { kind: 'leaf' } : node.kind === 'stem' ? { kind: 'stem', child: cs[0]! } : { kind: 'fork', left: cs[0]!, right: cs[1]! }); + } + } + return { nodes, roots: [mapped.get(root)!], exports: [{ name: 'tree', root: mapped.get(root)!, kind: 'term', abi: 'arboricx.abi.tree.v1' }], metadata: {} }; +} +export function treeResult(snapshot: GraphPreview, format: TreeResultFormat): { type: TreeResultFormat; value: string; file: TreeFile } { + const file = exportTree(snapshotBundle(snapshot), 'tree', format as TreeFormat); + return { type: format, value: file.text, file }; +} diff --git a/ext/demo-site/src/wasm-worker.ts b/ext/demo-site/src/wasm-worker.ts @@ -53,11 +53,14 @@ scope.onmessage = ({ data }) => { if (typeof m['export'] !== 'string' || m['export'].length > 512) throw new Error('Invalid export'); if (m['mode'] !== 'pure' && m['mode'] !== 'io') throw new Error('Unsupported execution mode'); const args = literals(m['arguments']), output = outputType(m['output']); running(); - const result = await session.run(m['export'], args, output, m['mode'], { + const host: import('./io.js').IOHost = { async before(event) { send({ type: 'trace', phase: 'before', event }); if (m['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, - }); + }; + const result = m['expression'] === undefined + ? await session.run(m['export'], args, output, m['mode'], host) + : await session.runExpression(sourceText(m['expression']), output, m['mode'], host); send({ type: 'done', result, stats: session.stats() }); } else if (m['type'] === 'export' && stored) { try { diff --git a/ext/demo-site/src/worker.ts b/ext/demo-site/src/worker.ts @@ -1,3 +1,5 @@ +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'; @@ -33,11 +35,11 @@ async function load(hash: BundleHash, localId: string | undefined): Promise<void 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 = exports.get(message.export); + 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 = graph.apply(fn, args); + 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(); }, @@ -78,6 +80,7 @@ scope.onmessage = ({ data }) => { } 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); } diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -1,247 +1,13 @@ -:root { color-scheme: light; --paper:#f5f2ea; --ink:#202d29; --muted:#626d65; --green:#235a43; --line:#d8dcd0; --panel:#fffef9; --code:#172a24; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } -* { box-sizing:border-box; } -html { scroll-behavior:smooth; } -body { margin:0; background:var(--paper); color:var(--ink); } -a { color:var(--green); text-underline-offset:4px; } -button,input { font:inherit; } -button,.button { border:1px solid var(--line); background:var(--panel); color:var(--ink); border-radius:6px; padding:10px 16px; cursor:pointer; text-decoration:none; display:inline-block; } -button:hover:not(:disabled),.button:hover { border-color:var(--green); } -button:disabled { cursor:default; opacity:.45; } -button.primary,.button.primary { background:var(--green); color:white; border-color:var(--green); } -:focus-visible { outline:3px solid #b65b28; outline-offset:3px; } -.wrap { max-width:1120px; margin:auto; padding-left:28px; padding-right:28px; } -header { display:flex; justify-content:space-between; align-items:center; padding-top:28px; padding-bottom:28px; border-bottom:1px solid var(--line); gap:20px; } -.wordmark { font-weight:800; font-size:25px; text-decoration:none; } -.wordmark span { font-size:13px; font-weight:400; color:var(--muted); } -nav { display:flex; gap:22px; font-size:14px; } -.hero { padding-top:80px; padding-bottom:86px; position:relative; } -.eyebrow { font:600 12px ui-monospace,monospace; letter-spacing:.12em; text-transform:uppercase; color:var(--green); } -.pronunciation { text-transform:none; letter-spacing:normal; } -h1 { font-size:clamp(48px,6vw,78px); line-height:1.03; letter-spacing:-.055em; font-weight:650; margin:22px 0; } -h1 span { color:var(--green); } -.intro { max-width:570px; font-size:19px; line-height:1.7; color:var(--muted); } -.shapes { display:flex; gap:35px; margin:28px 0 35px; } -.shapes div { width:100px; text-align:center; } -.shapes svg { width:100px; height:80px; display:block; fill:var(--green); } -.shapes > div:first-child circle { transform:translateY(18.5px); } -.shapes path { stroke:var(--green); stroke-width:2; fill:none; } -.shapes strong,.shapes code { display:block; font-size:13px; margin-top:5px; } -.shapes code { color:var(--muted); } -h2 { font-size:36px; letter-spacing:-.035em; font-weight:600; margin:8px 0 16px; } -h3 { font-size:15px; font-weight:650; margin:0; } -.section-heading { display:flex; align-items:center; justify-content:space-between; gap:20px; } -.badge { color:var(--green); border:1px solid var(--line); border-radius:30px; padding:7px 12px; font-size:12px; white-space:nowrap; } -.muted { color:var(--muted); line-height:1.7; max-width:850px; } -.demo-picker { display:flex; gap:10px; margin:25px 0 18px; flex-wrap:wrap; } -.demo-picker button[aria-pressed="true"] { background:var(--ink); color:var(--paper); border-color:var(--ink); } -.lab { display:grid; grid-template-columns:1fr 1fr; border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--panel); } -.source-panel { min-width:0; background:var(--code); color:#e3edde; } -.panel-heading { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:17px 20px; border-bottom:1px solid var(--line); } -.panel-heading span { font:11px ui-monospace,monospace; color:var(--muted); } -.source-panel .panel-heading { border-color:#385045; } -.source-panel .panel-heading span { color:#b1c1b7; } -pre { font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; } -.source-panel pre { padding:8px 20px 20px; margin:0; overflow:auto; max-height:480px; tab-size:2; } -.execution-panel { min-width:0; } -.execution-panel > :not(.panel-heading) { margin-left:20px; margin-right:20px; } -#terminal { white-space:pre-wrap; overflow-wrap:anywhere; min-height:145px; max-height:250px; overflow:auto; background:#f1f3eb; border:1px solid var(--line); padding:14px; border-radius:5px; } -#terminal:empty::before { content:"The tree is ready. Run it to begin."; color:var(--muted); } -#result { font:13px/1.6 ui-monospace,monospace; min-height:22px; overflow-wrap:anywhere; } -#result.error { color:#9b3422; } -.controls { display:flex; gap:8px; margin-top:15px; flex-wrap:wrap; } -.step-controls { display:flex; align-items:center; gap:15px; justify-content:space-between; margin-top:14px; margin-bottom:20px; font-size:12px; flex-wrap:wrap; } -.step-controls label { display:flex; align-items:center; gap:7px; } -.step-controls button { padding:7px 10px; } -label { font-size:12px; } -input:not([type="checkbox"]) { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:10px; min-width:0; width:100%; color:var(--ink); } -.input-row { display:flex; gap:8px; margin-top:8px; } -.input-row button { white-space:nowrap; } -#tree-controls { padding-top:16px; } -#tree-input { margin-top:7px; font-family:ui-monospace,monospace; } -.hint,.limits { font-size:12px; line-height:1.7; color:var(--muted); } -.hint button { border:0; padding:0; background:none; color:var(--green); text-decoration:underline; } -#tree-view { width:100%; height:140px; fill:var(--green); } -#tree-view line { stroke:var(--green); stroke-width:2; } -#tree-view text { font:11px ui-monospace,monospace; fill:var(--muted); } -.trace-panel { margin-top:18px; border:1px solid var(--line); border-radius:8px; background:var(--panel); } -#trace-empty { padding:0 20px; } -#trace { padding:0 20px; list-style:none; max-height:290px; overflow:auto; counter-reset:action; } -#trace li { padding:10px 0; border-bottom:1px solid var(--line); font:12px/1.6 ui-monospace,monospace; overflow-wrap:anywhere; white-space:pre-wrap; } -#trace li:last-child { border-bottom:0; } -#trace li.pending { color:var(--green); background:#edf2e7; } -.limits { margin:20px 0; max-width:950px; } -.explanation { padding-top:70px; padding-bottom:75px; } -.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-top:25px; padding-bottom:35px; border-top:1px solid var(--line); font-size:12px; line-height:1.8; color:var(--muted); } -[hidden] { display:none !important; } -@media (min-width:950px) { .shapes { position:absolute; right:38px; top:70px; flex-direction:column; gap:15px; } } -@media (max-width:740px) { .lab { grid-template-columns:1fr; } .source-panel pre { max-height:280px; } .explanation-grid { grid-template-columns:1fr; gap:14px; } .section-heading { align-items:start; flex-direction:column; gap:0; } .hero { padding-top:45px; padding-bottom:55px; } nav { gap:12px; } .wordmark span { display:none; } .wrap { padding-left:18px; padding-right:18px; } .trace-panel .panel-heading { align-items:start; flex-direction:column; } } -@media (prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } } -#bundle-hash { font:12px ui-monospace,monospace; } -#package-info { overflow-wrap:anywhere; } -.application-fields { padding-top:18px; } -.application-fields > label { display:block; margin-top:10px; margin-bottom:6px; } -select,textarea { font:inherit; background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:4px; padding:9px; width:100%; min-width:0; } -textarea { font:12px/1.5 ui-monospace,monospace; resize:vertical; } -select:disabled,textarea:disabled { opacity:.6; } -.codec-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:14px 0; } -.codec-row label { display:block; margin-bottom:6px; } -details { margin-top:10px; font-size:12px; } -details summary { cursor:pointer; color:var(--green); } -details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; } -#graph-result { white-space:pre-wrap; overflow-wrap:anywhere; max-height:250px; overflow:auto; padding:12px; background:#f1f3eb; border-radius:4px; font-size:11px; } -@media (max-width:430px) { .codec-row { grid-template-columns:1fr; } .wordmark { font-size:21px; } } -.compiler-section { margin-top:72px; } -.compiler-warning { background:#eaf0e6; border:1px solid var(--line); border-left:4px solid var(--green); border-radius:6px; padding:18px 20px; margin:20px 0; font-size:13px; } -.compiler-warning p { color:var(--muted); line-height:1.6; } -.compiler-warning label { display:flex; align-items:baseline; gap:8px; } -.compiler-source > label { display:block; margin:18px 20px 8px; font-size:12px; } -.compiler-source textarea { display:block; width:calc(100% - 40px); margin:0 20px; background:#172b24; color:#e6eee4; } -.compiler-source .demo-picker,.compiler-source .controls,.compiler-source .hint { margin:18px 20px; } -#compiler-terminal { min-height:80px; white-space:pre-wrap; overflow-wrap:anywhere; background:#f1f3eb; border:1px solid var(--line); border-radius:4px; padding:14px; font:12px/1.6 ui-monospace,monospace; } -#compiler-graph,#compiler-trace { white-space:pre-wrap; overflow-wrap:anywhere; max-height:250px; overflow:auto; padding:12px; background:#f1f3eb; border-radius:4px; font-size:11px; } -#compiler-stats { overflow-wrap:anywhere; } -@media(max-width:430px) { nav { gap:10px; } } -.compiler-source .hint { color:#c3d1c6; } - -.compiler-metrics { margin:18px 20px; } -.compiler-metrics summary { color:#c3d1c6; } -.compiler-source .compiler-metrics .hint { margin:10px 0; } - -/* Only the active tab participates in layout. */ -.workspace { padding-bottom:48px; } -.workspace-tabs { display:flex; gap:6px; padding:10px; border:1px solid var(--line); border-bottom:0; border-radius:10px 10px 0 0; background:#eaf0e6; } -.workspace-tabs button { flex:1; border-color:transparent; background:transparent; } -.workspace-tabs button[aria-selected="true"] { background:var(--panel); border-color:var(--line); color:var(--green); font-weight:650; } -.workspace-panels { border:1px solid var(--line); border-radius:0 0 10px 10px; background:var(--panel); } -.workspace-panel { display:none; min-width:0; padding:28px; } -.workspace-panel.is-active { display:block; } -.workspace-panel.explanation { padding-top:28px; padding-bottom:28px; } -@media(max-width:740px) { .workspace-panel { padding:20px 14px; } .workspace-panel.explanation { padding:20px 14px; } .workspace-tabs { gap:0; padding:7px; } .workspace-tabs button { padding:10px 5px; font-size:13px; } } - -/* Horizontal tree illustrations and centered CTA for handheld/tablet layouts. */ -@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) { - textarea,#bundle-hash { font-size:16px; } - .panel-heading { flex-wrap:wrap; } -} -@media (max-width:540px) { - header { flex-wrap:wrap; gap:8px 16px; padding-top:20px; padding-bottom:20px; } - nav { flex-wrap:wrap; gap:0 16px; } - nav a { display:inline-flex; align-items:center; min-height:44px; white-space:nowrap; } -} - - -.tree-export .execution-panel { padding-bottom:8px; } -.tree-export .source-panel { display:flex; flex-direction:column; } -.tree-export .source-panel .hint { color:#b1c1b7; margin:20px; } -.tree-export .source-panel .controls { margin:16px 20px 20px; } -#tree-provenance { overflow-wrap:anywhere; } -#tree-provenance.error { color:#9b3422; } -#tree-preview { white-space:pre-wrap; overflow-wrap:anywhere; max-height:400px; min-height:160px; flex:1; } -@media(max-width:540px) { - .workspace-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); } - .workspace-tabs button { min-height:44px; } - .program-input .input-row { flex-wrap:wrap; } - .program-input .input-row input { flex-basis:100%; } -} - -#source-editor { flex:1; width:100%; margin:0; min-height:320px; resize:vertical; border:0; border-radius:0; background:transparent; color:inherit; font:13px/1.6 ui-monospace,monospace; padding:20px; } -#source-editor.modified { background:#273c32; box-shadow:inset 4px 0 #e5b96b; } -#authorship { margin:0; padding:10px 20px; color:#c6dacb; font-size:13px; } -#authorship.modified { color:#ffda98; } -button.primary.draft { background:#8b581b; } -.source-panel > .controls,.scratch-examples,.compiler-metrics { margin:10px 20px; } -.scratch-examples,.compiler-metrics { color:#c6dacb; } -.scratch-examples .hint,.compiler-metrics .hint { color:inherit; } -#release-info { overflow-wrap:anywhere; } -.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; } -[hidden] { display:none !important; } - -@media(max-width:740px) { #source-editor { font-size:16px; height:320px; min-height:240px; max-height:360px; } } -.source-panel details > summary { color:#c6dacb; } - -/* A single primary path, with secondary controls disclosed on demand. */ -.workspace-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); } -.workspace-tabs button { min-width:0; min-height:44px; } -.program-choice { display:grid; grid-template-columns:auto minmax(0,1fr); align-items:center; gap:14px; margin:20px 0 12px; } -.program-choice label { font-weight:650; } -.program-choice label { font-size:14px; } -.program-choice select { font-size:16px; min-height:44px; } -.program-input { padding:14px 16px; border:1px solid var(--line); border-radius:6px; background:var(--paper); margin:12px 0; } -.program-input > label { font-size:14px; font-weight:600; } -.bundle-details { margin:12px 0 20px; } -.bundle-details .hint { margin:4px 0; font-size:12px; } -.bundle-details .hint { overflow-wrap:anywhere; } -.argument-heading { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-top:18px; } -.argument-heading button,.argument-row button { padding:7px 10px; font-size:12px; } -.argument-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 10px; margin:12px 0; } -.argument-row label { grid-column:1 / -1; } -.argument-row textarea { font-size:14px; } -.argument-row button { align-self:start; } -.argument-help { margin:12px 0; } -.argument-help textarea { margin-top:10px; } -.run-tools { margin-top:16px; margin-bottom:18px; border-top:1px solid var(--line); padding-top:12px; } -.trace-panel > summary { padding:14px 20px; } -@media(max-width:540px) { - .workspace-tabs { grid-template-columns:repeat(3,minmax(0,1fr)); } - .workspace-tabs button { font-size:12px; padding:10px 2px; } - .argument-row textarea { font-size:16px; } - .program-choice { grid-template-columns:minmax(0,1fr); gap:6px; } -} -.result-codec { margin:14px 0; } -.result-codec label,.run-tools > div > label { display:block; margin-bottom:6px; } -.run-tools > div { margin-top:12px; } - -/* Stacked source and execution share the hero's outer measure via .wrap. */ -.program-workspace .execution-panel { border:0; border-radius:0; padding-bottom:16px; background:var(--panel); } -.program-workspace .application-fields > select { max-width:440px; } -#argument-fields { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:0 18px; } -#argument-fields > .hint { grid-column:1 / -1; } -#program-actions { margin-bottom:14px; } -#draft-run-note { color:#8b581b; } -#compile-activity { margin:12px 20px; color:#e2d5af; } -#result-inspection { margin-bottom:16px; } -.source-disclosure { margin-top:24px; border:1px solid var(--line); border-radius:10px; overflow:hidden; } -.source-disclosure > summary { padding:16px 20px; font-size:15px; font-weight:650; } -.source-disclosure .source-panel { border-radius:0; } -#source-editor { flex:none; height:480px; min-height:320px; max-height:none; font-size:14px; } -#source-editor[readonly] { cursor:text; } -.compiler-source .controls a { color:#c6dacb; align-self:center; } -@media(max-width:740px) { - #argument-fields { grid-template-columns:minmax(0,1fr); } - #source-editor { font-size:16px; height:320px; min-height:240px; max-height:360px; } - .source-disclosure > summary { padding:14px 16px; } -} -#result:empty { display:none; } - -.execution-disclosure { margin-top:24px; border:1px solid var(--line); border-radius:10px; background:var(--panel); overflow:hidden; } -.execution-disclosure > summary { padding:16px 20px; font-size:15px; font-weight:650; } -.execution-summary { display:inline-flex; width:calc(100% - 24px); align-items:center; justify-content:space-between; gap:12px; } -.execution-summary #status { font-size:12px; font-weight:400; color:var(--muted); } -.execution-panel .trace-panel { margin:18px 20px; } -@media(max-width:740px) { .execution-disclosure > summary { padding:14px 16px; } } - -/* Keep version/load/download together and promote Load without hiding Download. */ -.package-toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; padding:16px; border:1px solid var(--line); border-radius:8px; background:var(--paper); } -.package-toolbar label { font-size:14px; font-weight:650; margin:0 0 6px; } -.package-toolbar .input-row { gap:10px; } -.package-toolbar button { min-height:44px; white-space:nowrap; } -.release-choice select { width:9rem; flex:none; } -.hash-choice { flex:1 1 320px; min-width:0; } -.hash-choice input { min-width:0; } -#download-bundle { font-weight:650; } -#bundle-download-status:empty { display:none; } -@media(max-width:740px) { - .package-toolbar { padding:12px; } - .release-choice,.hash-choice { width:100%; } - .release-choice select { flex:1; width:auto; } - #download-bundle { width:100%; } -} +:root{color-scheme:light;--paper:#f5f2ea;--ink:#202d29;--muted:#626d65;--green:#235a43;--line:#d8dcd0;--panel:#fffef9;--code:#172a24;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:var(--green);text-underline-offset:4px}button,input,select{font:inherit}button,.button{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:6px;padding:10px 14px;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px}button:hover:not(:disabled),.button:hover{border-color:var(--green)}button:disabled{cursor:default;opacity:.45}button.primary,.button.primary{background:var(--green);color:white;border-color:var(--green)}:focus-visible{outline:3px solid #b65b28;outline-offset:3px}.wrap{max-width:1120px;margin:auto;padding-inline:28px}header{display:flex;justify-content:space-between;align-items:center;padding-block:28px;border-bottom:1px solid var(--line);gap:20px}.wordmark{font-weight:800;font-size:25px;text-decoration:none}.wordmark span{font-size:13px;font-weight:400;color:var(--muted)}nav{display:flex;gap:22px;font-size:14px}.hero{padding-block:64px;position:relative}.eyebrow{font:600 12px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:var(--green)}.pronunciation{text-transform:none;letter-spacing:normal}h1{font-size:clamp(48px,6vw,78px);line-height:1.03;letter-spacing:-.055em;font-weight:650;margin:22px 0}h1 span{color:var(--green)}.intro{max-width:570px;font-size:19px;line-height:1.7;color:var(--muted)}.shapes{display:flex;gap:35px;margin:28px 0 35px}.shapes div{width:100px;text-align:center}.shapes svg{width:100px;height:80px;display:block;fill:var(--green)}.shapes>div:first-child circle{transform:translateY(18.5px)}.shapes path{stroke:var(--green);stroke-width:2;fill:none}.shapes strong,.shapes code{display:block;font-size:13px;margin-top:5px}.shapes code{color:var(--muted)}h2{font-size:36px;letter-spacing:-.035em;font-weight:600;margin:8px 0 16px}h3{font-size:15px;font-weight:650;margin:0 0 12px}.hint{font-size:12px;line-height:1.7;color:var(--muted);overflow-wrap:anywhere}label{display:block;font-size:12px;font-weight:600;margin-bottom:6px}select,textarea,input:not([type=checkbox]){font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:9px;width:100%;min-width:0}select{min-height:44px}textarea{resize:vertical;font:14px/1.6 ui-monospace,monospace}pre{font:14px/1.65 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}details{margin-top:14px;font-size:13px}summary{cursor:pointer;color:var(--green);min-height:32px}button,input,select,textarea{touch-action:manipulation}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}.error{color:#9b3422}.input-row{display:flex;gap:8px;margin-bottom:12px}.input-row button{white-space:nowrap}.check{display:flex;align-items:center;gap:8px;font-weight:400;margin-block:14px}.check input{flex:none}.demo-picker{display:flex;flex-wrap:wrap;gap:8px}.demo-picker button{font-size:12px} +/* One workspace, one output surface. No nested cards or inactive panels. */ +.workspace{max-width:1064px;margin:0 auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:clip;scroll-margin-top:16px}.editor-toolbar{position:sticky;top:0;z-index:5;display:flex;align-items:end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--line);background:#eaf0e6}.toolbar-selection{flex:1 1 220px;min-width:0}.toolbar-runtime{flex:0 1 150px;min-width:115px}.toolbar-actions{display:flex;gap:8px;align-items:center}.editor-toolbar #status{flex-basis:100%;font-size:12px;color:var(--muted);min-height:18px}.editor-body{display:grid;grid-template-columns:minmax(0,1fr)}.editor-body.settings-open{grid-template-columns:minmax(0,1fr) 320px}.editor-main{min-width:0}.source-panel{background:var(--code);color:#e3edde;min-width:0}.source-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px 8px}.source-heading h2{font-size:14px;letter-spacing:0;margin:0}.source-heading span{font:11px ui-monospace,monospace;color:#b1c1b7}#authorship{font-size:12px;color:#b1c1b7;margin:0;padding:0 20px 12px}#authorship.modified{color:#ffda98}#source-editor{display:block;width:100%;height:460px;min-height:240px;max-height:none;border:0;border-radius:0;padding:18px 20px;margin:0;color:inherit;background:transparent;font:14px/1.65 ui-monospace,monospace;tab-size:2}#source-editor.modified{background:#273c32;box-shadow:inset 4px 0 #e5b96b}#source-editor:disabled{opacity:.75}#source-notes{margin:0;padding:18px 20px;max-height:460px;overflow:auto;white-space:pre;tab-size:2}#source-availability,#compile-diagnostic,#compile-activity{margin:0;padding:10px 20px;color:#e2d5af}#compile-diagnostic{color:#ffc5b9}.source-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #385045}.source-actions .hint{color:#b1c1b7;margin:0}.source-actions button{font-size:12px;padding:6px 10px;background:transparent;color:#e3edde;border-color:#385045}.expression-dock{padding:16px 20px;background:#eef2e8;border-top:1px solid var(--line)}#expression-inputs label{font-size:13px}#expression-inputs label span{font-weight:400;color:var(--muted)}#expression-editor{font:16px/1.6 ui-monospace,monospace;min-height:92px;background:var(--panel)}#expression-help{margin:6px 0 0}#draft-run-note{color:#8b581b}.argument-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.argument-heading h3{margin:0}#argument-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.argument-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 10px;margin-top:12px}.argument-row label{grid-column:1/-1}.argument-row textarea{font-size:16px}.argument-row button{font-size:12px;align-self:start} +/* Docked settings; mobile turns the same inspector into a modal sheet. */ +#settings-panel{min-width:0;padding:18px;background:var(--paper);border-left:1px solid var(--line);font-size:13px}#settings-panel section{padding-block:20px;border-bottom:1px solid var(--line)}#settings-panel section:last-child{border:0}.inspector-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.inspector-heading h2{font-size:16px;margin:0;letter-spacing:0}#settings-panel button{font-size:12px}#settings-panel .hint{margin-bottom:0}#settings-panel details>p{margin-top:8px}#package-info,#release-info,#compiler-stats{overflow-wrap:anywhere}#download-bundle{margin-top:12px;width:100%}#bundle-download-status:empty{display:none} +.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: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(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -53,8 +53,33 @@ try { return result.result.value; }; for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));} - const report=await evaluate(`import(${JSON.stringify(process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); assert.deepEqual(exceptions,[]); + if(process.env.TEST_LIVE_UX||process.env.TEST_IDE){ + 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(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.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'); + await evaluate("document.getElementById('settings-close').focus()"); + await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',modifiers:8}); + await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',modifiers:8}); + assert.equal(await evaluate("document.getElementById('settings-panel').contains(document.activeElement)"),true,'Focus remains in the sheet'); + await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape'}); + 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}); + 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')); + } + } if(stopMemory){report.browserMemory=await stopMemory(marks);stopMemory=undefined;} if(process.env.BENCHMARK_REPORT)await writeFile(process.env.BENCHMARK_REPORT,JSON.stringify(report,null,2)+'\n'); if(report.parity)console.log(JSON.stringify(report.parity)); diff --git a/ext/demo-site/test/backend-probe.mjs b/ext/demo-site/test/backend-probe.mjs @@ -19,11 +19,12 @@ export async function run(control, selectedBackend='haskell-wasm') { const status=s=>`document.getElementById('status').textContent===${JSON.stringify(s)}`; const click=async id=>evaluate(`document.getElementById(${JSON.stringify(id)}).click()`); const select=async id=>{await evaluate(`document.getElementById('program-picker').value='demo:${id}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready'));}; - await wait("typeof document.getElementById('tab-playground')?.onclick==='function'"); + await wait(status('Ready')); assert.equal(document.getElementById('runtime-backend').value,'haskell-wasm','Fresh page defaults to Haskell'); - await click('tab-playground');await wait(status('Ready')); + document.getElementById('use-guided-inputs').checked=true;document.getElementById('use-guided-inputs').dispatchEvent(new Event('change')); + await wait(status('Ready')); await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + assert.equal(await evaluate("document.getElementById('result').textContent"),'42'); assert.equal((await requestLog()).some(x=>x.endsWith('/runtime/haskell/reactor.wasm')),true,'Default actually loads Haskell'); await fault({runtime:true}); await selectBackend();await wait(status('Stopped')); @@ -32,44 +33,45 @@ export async function run(control, selectedBackend='haskell-wasm') { await fault({runtime:false});await select('intro'); assert.equal((await requestLog()).some(x=>x.endsWith('/data/compiler.dag')||x.endsWith('/hash/'+compilerHash)),false,'packages do not load a compiler kit'); await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + assert.equal(await evaluate("document.getElementById('result').textContent"),'42'); // The same real page can reinitialize each supported published package. for(const name of ['rgb','microscope','shipping','events','registration','migration']) { await select(name);await click('run');await wait(status('Finished')); - assert.match(await evaluate("document.getElementById('result').textContent"),/^Returned /); + assert.match(await evaluate("document.getElementById('result').textContent"),/.+/); } await select('greet');await click('run');await wait(status('Waiting for input')); await evaluate("document.getElementById('line-input').value='λ雪🙂';document.getElementById('input-form').requestSubmit()");await wait(status('Finished')); assert.match(await evaluate("document.getElementById('terminal').textContent"),/λ雪🙂/); // Stored export remains backend-independent and does not run the program. - await click('tab-export-definition'); - await evaluate("document.getElementById('tree-format').value='dag';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); - await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); - assert.match(await evaluate("document.getElementById('tree-preview').textContent"),/tree/); - await click('tab-playground');await select('intro'); + await evaluate("document.getElementById('output-target').value='definition';document.getElementById('output-target').dispatchEvent(new Event('change'))"); + await evaluate("document.getElementById('output-type').value='dag';document.getElementById('output-type').dispatchEvent(new Event('change'))"); + await click('run');await wait("!document.getElementById('copy-output').disabled"); + assert.match(await evaluate("document.getElementById('result').textContent"),/tree/); + await evaluate("document.getElementById('output-target').value='result';document.getElementById('output-target').dispatchEvent(new Event('change'))");await select('intro'); await evaluate("document.getElementById('source-editor').value=document.getElementById('source-editor').value.replace('first 42 7','first 43 7');document.getElementById('source-editor').dispatchEvent(new Event('input'))"); await click('compile');await wait(status('Compiled')); assert.equal((await requestLog()).some(x=>x.endsWith('/data/compiler.dag')||x.endsWith('/hash/'+compilerHash)),true); assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/WASM linear memory/); assert.doesNotMatch(await evaluate("document.getElementById('compiler-stats').textContent"),/work units|heap nodes/); assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); - for(let i=0;i<3;i++){await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"');} + for(let i=0;i<3;i++){await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'43');} await evaluate("document.getElementById('output-type').value='string'");await click('run');await wait(status('Stopped')); assert.equal(await evaluate("document.getElementById('run').disabled"),false,'recoverable run errors preserve definitions'); await evaluate("document.getElementById('output-type').value='number';document.getElementById('source-editor').value+='\\n-- unapplied';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),true); - await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); + assert.equal(document.getElementById('run').disabled,true,'Unapplied edits cannot run old definitions'); + await wait(status('Compiled'));await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'43'); await click('restore-source');await wait(status('Ready')); // A practical draft exercises selected environment bytes/pin/name resolution. await select('shipping');await evaluate("document.getElementById('source-editor').value+='\\n-- adapter test';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); await click('compile');await wait(status('Compiled'));await click('run');await wait(status('Finished')); // Scratch compilation selects the runtime's authenticated default environment. await evaluate("document.querySelector('[data-compiler-example=contracts]').click()");await click('compile');await wait(status('Compiled')); - await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'42'); await fault({compilerPin:true}); await evaluate("document.getElementById('source-editor').value+='\\n-- pin control';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - await click('compile');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/Runtime compiler pin mismatch|HTTP|fetch/i); + await click('compile');await wait(status('Compilation failed')); + assert.match(await evaluate("document.getElementById('compile-diagnostic').textContent"),/Runtime compiler pin mismatch|HTTP|fetch/i); assert.equal(await evaluate("document.getElementById('run').disabled"),true); await fault({compilerPin:false}); await evaluate("document.querySelector('[data-compiler-example=input]').click()");await click('compile');await wait(status('Compiled')); @@ -80,11 +82,11 @@ export async function run(control, selectedBackend='haskell-wasm') { await evaluate("document.getElementById('step-mode').checked=true");await click('run');await wait(status('Paused before action')); await click('next');await wait(status('Waiting for input')); await evaluate("document.getElementById('line-input').value='restart🙂';document.getElementById('input-form').requestSubmit()");await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "restart🙂"'); + assert.equal(await evaluate("document.getElementById('result').textContent"),'restart🙂'); // An actual non-returning reduction must remain stoppable from the page. await evaluate("document.getElementById('source-editor').value='id = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); await click('compile');await wait(status('Compiled')); - await evaluate("document.getElementById('mode').value='pure';document.getElementById('output-type').value='tree';document.getElementById('arguments').value=JSON.stringify([{type:'tree',value:'212110021100'},{type:'tree',value:'212110021100'}])"); + await evaluate("document.getElementById('mode').value='pure';document.getElementById('output-type').value='tricu';document.getElementById('arguments').value=JSON.stringify([{type:'tree',value:'212110021100'},{type:'tree',value:'212110021100'}])"); await click('run');await wait(status('Running')); await new Promise(r=>setTimeout(r,150));assert.equal(await evaluate("document.getElementById('status').textContent"),'Running'); await click('stop');await wait(status('Stopped'));assert.equal(await evaluate("document.getElementById('run').disabled"),true); 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==='/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;} if(failRuntime&&(path.endsWith('/reactor.wasm')||path.endsWith('/arboricx.wasm')))throw Error('Injected artifact failure'); if(compilerMismatch&&path==='/nested/site/compiler.json') { @@ -46,7 +48,7 @@ export async function startSiteServer(directory,autoRun=false) { res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json','.css':'text/css'})[extname(file)]??'application/octet-stream'); let bytes=await readFile(file); if(autoRun&&path==='/nested/site/index.html')bytes=Buffer.from(bytes.toString()+`<script type="module"> - try{const m=await import(${JSON.stringify(process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} + try{const m=await import(${JSON.stringify(process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} catch(e){await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:false,error:String(e.stack||e)})});} </script>`); res.end(bytes); diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs @@ -1,1150 +1,12 @@ #!/usr/bin/env node -// Opt-in JS-reference Chromium integration, dependency-free. Uses a fresh profile. -// The backend-selector suites separately exercise the unchanged Haskell default. -// node ext/demo-site/test/browser.mjs [chromium-path] [optional-screenshot.png] -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { createServer } from 'node:http'; -import { readFile, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { resolve, extname, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createHash } from 'node:crypto'; -import { makeBundle, sizedBundle } from './bundle-fixture.mjs'; - -const root = resolve(process.env['ARBORICX_SITE_ROOT'] || fileURLToPath(new URL('../', import.meta.url))); -const dependencyBytes = hash => readFile(process.env['ARBORICX_REGISTRY_STORE'] - ? resolve(process.env['ARBORICX_REGISTRY_STORE'],'objects',hash.slice(0,3),hash+'.arboricx') - : fileURLToPath(new URL('../bundles/'+hash+'.arboricx',import.meta.url))); -const prefix = '/nested/tricu/'; // Exercise static subdirectory deployment. -const registry = new Map(); -const pin = bytes => createHash('sha256').update(bytes).digest('hex'); -const published = {}; -for(const [name,size] of [['boundary',16_777_216],['oversized',16_777_217]]){const bytes=sizedBundle(size);published[name]=pin(bytes);registry.set(published[name],bytes);} -for(const name of ['id','append','size']) { - const bytes=await readFile(new URL(`../../../test/fixtures/${name}.arboricx`,import.meta.url)); - published[name]=pin(bytes); registry.set(published[name],bytes); -} -const catalogue=JSON.parse(await readFile(resolve(root,'catalogue.json'))); -const greeting=catalogue.find(entry=>entry.id==='greet'); -published.greet=greeting.hash; -const packageIndex=JSON.parse(await readFile(resolve(root,'packages.json'))); -for(const entry of [...catalogue,...packageIndex.releases])registry.set(entry.hash,await dependencyBytes(entry.hash)); -const environments=JSON.parse(await readFile(resolve(root,'environments.json'))); -for(const profile of Object.values(environments.profiles))if(profile)registry.set(profile.hash,await dependencyBytes(profile.hash)); -const records=[[]];for(let i=1;i<32;i++)records.push([i-1,i-1]); -const previewBundle=makeBundle(records,[['main',14]]);published.preview=pin(previewBundle);registry.set(published.preview,previewBundle); -const huge=makeBundle(records);published.huge=pin(huge);registry.set(published.huge,huge); -const larger=makeBundle(records,[['main',21]]);published.larger=pin(larger);registry.set(published.larger,larger); -// Package the previously Haskell-verified lazy counterexample and q without -// using the new evaluator to manufacture the expected result. -function indexed(text, nodes, memo) { - let at=0; - function read(){const tag=text[at++];let children=[];if(tag==='1')children=[read()];else if(tag==='2')children=[read(),read()];const key=tag+','+children.join(',');if(memo.has(key))return memo.get(key);const id=nodes.length;nodes.push(children);memo.set(key,id);return id;} - const root=read();assert.equal(at,text.length);return root; -} -const lazyNodes=[],memo=new Map(); -const f=indexed('2120200212110021100',lazyNodes,memo),q=indexed('212110021100',lazyNodes,memo); -const lazy=makeBundle(lazyNodes,[['f',f],['q',q]]);published.lazy=pin(lazy);registry.set(published.lazy,lazy); -const invalid=Buffer.from(huge);invalid.writeUInt16BE(1,42);published.invalid=pin(invalid);registry.set(published.invalid,invalid); - -const requests = []; -let compilerFault, indexOverride; -const compilerDescriptor=JSON.parse(await readFile(resolve(root,'compiler.json'))); -registry.set(compilerDescriptor.hash,await dependencyBytes(compilerDescriptor.hash)); -const server = createServer(async (request, response) => { - try { - const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); requests.push(pathname); - response.setHeader('Cache-Control','no-store'); - if(indexOverride && pathname.endsWith('/packages.json')){ - response.setHeader('Content-Type','application/json');response.end(JSON.stringify(indexOverride));return; - } - if(compilerFault==='descriptor' && pathname.endsWith('/compiler.json')){ - response.setHeader('Content-Type','application/json');response.end(JSON.stringify({...compilerDescriptor,backend:'optimized'}));return; - } - if(compilerFault==='oversize' && pathname===`/_arboricx/bundle/hash/${compilerDescriptor.hash}`){ - response.setHeader('Content-Length','2097153');response.end();return; - } - if(compilerFault==='pin' && pathname===`/_arboricx/bundle/hash/${compilerDescriptor.hash}`){ - response.end(registry.get(published.id));return; - } - if(pathname.startsWith(prefix+'bundles/'))throw new Error('Bundles are not static assets'); - if(pathname.startsWith('/_arboricx/bundle/hash/')) { - const hash=pathname.slice('/_arboricx/bundle/hash/'.length); - if(hash==='a'.repeat(64)){response.setHeader('Content-Length','16777217');response.end();return;} - const bytes=hash==='f'.repeat(64)?registry.get(published.id):registry.get(hash); - if(!bytes)throw new Error('missing bundle'); - response.setHeader('Content-Type','application/vnd.arboricx.bundle');response.end(bytes);return; - } - if (!pathname.startsWith(prefix)) throw new Error('outside mount'); - const path = resolve(root, './' + pathname.slice(prefix.length)); - if (path !== root && !path.startsWith(root + sep)) throw new Error('outside root'); - const file = path.endsWith(sep) || path === root ? resolve(path, 'index.html') : path; - const types = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.wasm': 'application/wasm' }; - response.setHeader('Content-Type', types[extname(file)] || 'text/plain'); - let content=await readFile(file); - // Explicitly select the reference backend before deferred app initialization. - // This suite tests JS checkpoints/counters, not the production default. - if(extname(file)==='.html')content=Buffer.from(content.toString().replace('</body>', - '<script>document.getElementById("runtime-backend").value="js";</script></body>')); - response.end(content); - } catch { response.writeHead(404); response.end('Not found'); } -}); -await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); -const url = `http://127.0.0.1:${server.address().port}${prefix}index.html`; -const profile = await mkdtemp(resolve(tmpdir(), 'tricu-demo-chromium-')); -let browser, socket; -let next = 0; const pending = new Map(); -try { - browser = spawn(process.argv[2] || 'chromium', ['--headless', '--no-sandbox', '--disable-dev-shm-usage', - '--js-flags=--max-old-space-size=768', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }); - const debuggerURL = await new Promise((resolve, reject) => { - let stderr = ''; - const timer = setTimeout(() => reject(new Error('Chromium startup timed out: ' + stderr)), 15_000); - browser.on('error', error => { clearTimeout(timer); reject(error); }); - browser.on('exit', code => { clearTimeout(timer); reject(new Error('Chromium exited: ' + code + '\n' + stderr)); }); - browser.stderr.on('data', chunk => { - stderr += chunk; - const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); - if (match) { clearTimeout(timer); resolve(match[1]); } - }); - }); - const endpoint = new URL(debuggerURL); - const tab = await (await fetch(`http://${endpoint.host}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); - socket = new WebSocket(tab.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); - const exceptions = []; - socket.onmessage = ({ data }) => { - const event = JSON.parse(data); - if (event.id) { - const waiter = pending.get(event.id); pending.delete(event.id); - if (!waiter) return; - clearTimeout(waiter.timer); - if (event.error) waiter.reject(new Error(JSON.stringify(event.error))); - else waiter.resolve(event.result); - } else if (event.method === 'Runtime.exceptionThrown') exceptions.push(event.params); - }; - const send = (method, params = {}) => new Promise((resolve, reject) => { - const id = ++next; - const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 10_000); - pending.set(id, { resolve, reject, timer }); - socket.send(JSON.stringify({ id, method, params })); - }); - await send('Runtime.enable'); await send('Page.enable'); - const evaluate = async expression => { - const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); - if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); - return result.result.value; - }; - const wait = async (condition, seconds = 8) => { - const until = Date.now() + seconds * 1000; - while (Date.now() < until) { - if (await evaluate(condition)) return; - await new Promise(resolve => setTimeout(resolve, 25)); - } - throw new Error('Timed out: ' + condition + '\n' + await evaluate('document.body.innerText')); - }; - let navigation = 0; - const navigate = async (method, params = {}) => { - // A CDP navigation response is not a document-readiness barrier. Mark the - // old global so a still-live old handler cannot satisfy our next wait. - const marker = `browser-navigation-${++navigation}`; - await evaluate(`globalThis.__tricuBrowserNavigation = ${JSON.stringify(marker)}`); - const reply = await send(method, params); - // Fragment-only Page.navigate keeps the document (CDP omits loaderId). - if (method === 'Page.navigate' && !reply.loaderId) { - assert.equal(reply.errorText, undefined); - await wait(`document.readyState === 'complete' && location.href === ${JSON.stringify(params.url)}`); - return; - } - await wait(`document.readyState === 'complete' && globalThis.__tricuBrowserNavigation !== ${JSON.stringify(marker)}`); - }; - const status = text => `document.getElementById('status')?.textContent === ${JSON.stringify(text)}`; - const click = async id => { - if (!id.startsWith('tab-')) await evaluate(`(()=>{const panel=document.getElementById(${JSON.stringify(id)}).closest('[role="tabpanel"]');if(panel)document.getElementById(panel.getAttribute('aria-labelledby')).click();})()`); - await evaluate(`document.getElementById(${JSON.stringify(id)}).click()`); - }; - const openPlayground = async () => { - await wait("typeof document.getElementById('tab-playground')?.onclick === 'function'"); - await click('tab-playground'); await wait(status('Ready')); - }; - await navigate('Page.navigate', { url }); - await wait("typeof document.getElementById('tab-playground')?.onclick === 'function'"); - assert.equal(await evaluate("document.getElementById('tab-how').getAttribute('aria-selected')"),'true'); - assert.equal(await evaluate("document.getElementById('playground').inert"),true); - assert.equal(requests.some(path=>path.endsWith('/catalogue.json')||path.endsWith('/compiler.json')||path.startsWith('/_arboricx/bundle/hash/')),false); - await evaluate("document.getElementById('tab-how').focus();document.getElementById('tab-how').dispatchEvent(new KeyboardEvent('keydown',{key:'End',bubbles:true}))"); - await wait(status('Ready')); - assert.equal(await evaluate("document.activeElement.id"),'tab-export-definition'); - assert.equal(await evaluate("document.getElementById('package-selection').closest('[role=tabpanel]').id"),'export-definition'); - assert.equal(await evaluate("document.getElementById('tree-definition').value"),'answer'); - assert.equal(await evaluate("document.getElementById('terminal').textContent"),''); - await evaluate("document.getElementById('tab-export-definition').dispatchEvent(new KeyboardEvent('keydown',{key:'Home',bubbles:true}))"); - await evaluate("document.getElementById('tab-how').focus();document.getElementById('tab-how').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))"); - await wait(status('Ready')); - assert.equal(await evaluate("document.activeElement.id"),'tab-playground'); - const paneHeight=()=>evaluate("document.querySelector('.workspace-panels').getBoundingClientRect().height"); - const assertPaneFits = async () => { - const sizes=await evaluate("(()=>{const r=document.querySelector('.workspace-panels').getBoundingClientRect(),p=document.querySelector('.workspace-panel.is-active').getBoundingClientRect();return {row:r.height,active:p.height};})()"); - assert.ok(Math.abs(sizes.row-sizes.active-2)<1,'pane fits active content'); - return sizes.row; - }; - const heights=[]; - for(const id of ['tab-export-definition','tab-how','tab-playground']) {await click(id);heights.push(await assertPaneFits());} - assert.ok(new Set(heights.map(Math.round)).size>1,'height changes between tabs'); - assert.equal(await evaluate("document.querySelectorAll('[role=tab]').length"),3); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); - assert.deepEqual(await evaluate("[...document.querySelectorAll('#program-picker optgroup')].map(g=>g.label)"),['Examples','Published packages']); - assert.equal(await evaluate("document.querySelector('#program-picker option[value=hash]').textContent"),'Load by bundle hash…'); - assert.deepEqual(await evaluate("Array.from([...document.getElementById('program-picker').querySelectorAll('optgroup')].find(g=>g.label==='Published packages').querySelectorAll('option'),o=>o.value)"),packageIndex.releases.filter(r=>!r.demo).map(r=>'package:'+r.name).filter((v,i,a)=>a.indexOf(v)===i)); - assert.equal(await evaluate("document.getElementById('program-release').hidden"),false,'single-release examples keep an active version toolbar'); - await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); - assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),['0.1.2','0.1.1','0.1.0']); - assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.2'); - await evaluate("document.getElementById('package-version').value='0.1.0';document.getElementById('package-version').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - await click('tab-export-definition'); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:rgb'); - assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.0'); - await click('load-release');await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:rgb'); - assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(packageIndex.releases.find(r=>r.name==='rgb'&&r.version==='0.1.0').hash)); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/rgb@0.1.0/); - await click('tab-playground'); - assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'older release does not borrow current editable source'); - await evaluate("document.getElementById('package-version').value='0.1.2';document.getElementById('package-version').dispatchEvent(new Event('change'))"); - await click('load-release');await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-editor').hidden"),false,'current release retains curated source and presets'); - const beforeChoice=requests.filter(p=>p.startsWith('/_arboricx/bundle/hash/')).length; - await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); - assert.equal(await evaluate("document.getElementById('program-hash').hidden"),true); - assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.5','published packages default to newest release'); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - assert.match(await evaluate("document.getElementById('package-info').textContent"),/No bundle loaded/); - for(const name of [...new Set(packageIndex.releases.filter(r=>!r.demo).map(r=>r.name))]) { - await evaluate(`document.getElementById('program-picker').value=${JSON.stringify('package:'+name)};document.getElementById('program-picker').dispatchEvent(new Event('change'))`); - assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),packageIndex.releases.filter(r=>r.name===name).map(r=>r.version).sort().reverse(),`versions belong to ${name}`); - assert.equal(await evaluate("document.getElementById('package-version').selectedIndex"),0,`newest version selected for ${name}`); - } - await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - await click('tab-export-definition'); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'package:tricu-base'); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - assert.equal(requests.filter(p=>p.startsWith('/_arboricx/bundle/hash/')).length,beforeChoice,'pending choice does not load on pane change'); - await evaluate("document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("document.getElementById('program-release').hidden"),true); - assert.equal(await evaluate("document.getElementById('program-hash').hidden"),false); - assert.equal(await evaluate("document.activeElement.id"),'bundle-hash'); - await evaluate("document.getElementById('bundle-hash').value='invalid'"); - await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter',windowsVirtualKeyCode:13,text:'\r'}); - await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}); - await wait(status('Stopped')); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/bundle hash/); - await click('tab-playground'); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'hash'); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - console.log('PASS unified Program selector / visible hash option / mutually exclusive inputs / pending selections shared with Export / invalid hash fails closed'); - assert.equal(await evaluate("document.querySelectorAll('.limits-editor,#uncapped,#compiler-uncapped').length"),0); - console.log('PASS Ecosystem default / deferred package load / ARIA keyboard tabs / dynamic active height / no resource editors'); - assert.equal(requests.some(path=>path.endsWith('/compiler.json')),false); - assert.equal(await evaluate("document.getElementById('compile').disabled"),true); - assert.equal(await evaluate("document.getElementById('export').value"),'answer'); - assert.match(await evaluate("document.getElementById('source-editor').value"),/Start here: a small tricu language guide/); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - for(const [name,expected] of [['identity','Returned string: "Hello!"'],['first','Returned number: "7"'],['greeting','Returned string: "Hello from tricu!"'],['numbers','Returned list: tree graph'],['leaf','Returned tree: tree graph'],['stem','Returned tree: tree graph'],['fork','Returned tree: tree graph']]){ - await evaluate(`document.getElementById('export').value='${name}';document.getElementById('export').dispatchEvent(new Event('change'))`); - await click('run');await wait(status('Finished')); - const resultText=await evaluate("document.getElementById('result').textContent"); - if(expected.endsWith('tree graph'))assert.ok(resultText.startsWith(expected)); - else assert.equal(resultText,expected); - } - await evaluate("document.getElementById('export').value='identity';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('argument-0').value='\"field input\"';document.getElementById('argument-0').dispatchEvent(new Event('input'))"); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "field input"'); - await evaluate("document.getElementById('argument-0').value='[1 2 3]';document.getElementById('argument-0').dispatchEvent(new Event('input'));document.getElementById('output-type').value='list'"); - await click('run');await wait(status('Finished')); - assert.match(await evaluate("document.getElementById('result').textContent"),/^Returned list:/); - await click('add-argument'); - assert.equal(await evaluate("document.querySelectorAll('#argument-fields textarea').length"),2); - await evaluate("document.querySelectorAll('#argument-fields button')[1].click()"); - assert.equal(await evaluate("document.querySelectorAll('#argument-fields textarea').length"),1); - await evaluate("document.getElementById('raw-arguments').checked=true;document.getElementById('raw-arguments').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("JSON.parse(document.getElementById('arguments').value)[0].type"),'list'); - await evaluate("document.getElementById('raw-arguments').checked=false;document.getElementById('raw-arguments').dispatchEvent(new Event('change'))"); - await evaluate("document.getElementById('argument-0').value='unknownName';document.getElementById('argument-0').dispatchEvent(new Event('input'))"); - await click('run');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/Expected a literal/); - assert.equal(await evaluate("document.getElementById('argument-0').value"),'unknownName'); - await evaluate("document.getElementById('export').value='answer';document.getElementById('export').dispatchEvent(new Event('change'))"); - console.log('PASS Start here default / commented guide / all eight export presets'); - await evaluate("document.getElementById('export').value='identity';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value='not argument JSON'"); - await evaluate("document.querySelector('[data-open-pane=export-definition]').click()"); - // Native fragment navigation focuses the newly visible, focusable panel. - assert.equal(await evaluate("document.activeElement.id"),'export-definition'); - for(const [format,expected] of [['ternary','21100'],['tricu','(t (t (t t)) t)'],['triangle','(△ (△ (△ △)) △)'],['dag',null]]) { - await evaluate(`document.getElementById('tree-format').value='${format}';document.getElementById('tree-format').dispatchEvent(new Event('change'))`); - assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),true); - await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); - const text=await evaluate("document.getElementById('tree-preview').textContent"); - if(expected)assert.equal(text,expected);else assert.match(text,/tree :n[0-9]+\ntree\n$/); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/definition identity/); - assert.equal(await evaluate("document.getElementById('arguments').value"),'not argument JSON'); - } - await evaluate(`window.__originalClipboard=navigator.clipboard.writeText;navigator.clipboard.writeText=async text=>{window.__copiedTree=text;}; - window.__originalCreateURL=URL.createObjectURL;URL.createObjectURL=blob=>{window.__downloadedTree=blob.text();return window.__originalCreateURL(blob);}; - window.__downloadListener=event=>{if(event.target instanceof HTMLAnchorElement && event.target.download){event.preventDefault();window.__downloadName=event.target.download;}}; - document.addEventListener('click',window.__downloadListener);`); - await click('copy-tree');await wait("document.getElementById('tree-provenance').textContent.includes('Copied complete tree')"); - await click('download-tree'); - assert.equal(await evaluate('window.__copiedTree'),await evaluate('window.__downloadedTree')); - assert.match(await evaluate('window.__downloadName'),/^identity-[0-9a-f]{12}\.dag$/); - const retainedExport=await evaluate("document.getElementById('tree-preview').textContent"); - await click('tab-playground'); await click('tab-export-definition'); - assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),false); - assert.equal(await evaluate("document.getElementById('tree-preview').textContent"),retainedExport); - - await evaluate("navigator.clipboard.writeText=window.__originalClipboard;URL.createObjectURL=window.__originalCreateURL;document.removeEventListener('click',window.__downloadListener);document.getElementById('tree-definition').value='leaf';document.getElementById('tree-definition').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),true); - assert.equal(await evaluate("document.getElementById('tree-preview').hidden"),true); - assert.equal(await evaluate("document.getElementById('export').value"),'leaf'); - await click('tab-playground'); - console.log('PASS stored definition export after Run / all formats / no argument parsing / provenance / copy-download / selection invalidation'); - - const assertSourceFill=async()=>{ - const layout=await evaluate("(()=>{const panel=document.querySelector('#playground .source-panel'),source=panel.querySelector('#source-editor:not([hidden]),#source-notes:not([hidden])');const p=panel.getBoundingClientRect(),s=source.getBoundingClientRect();return {left:s.left,right:s.right,pLeft:p.left,pRight:p.right,height:s.height};})()"); - assert.ok(layout.left>=layout.pLeft-1 && layout.right<=layout.pRight+1,'source stays within its panel'); - assert.ok(layout.height>0,'source is visible'); - const widths=await evaluate("({source:document.querySelector('#playground .source-panel').getBoundingClientRect().width,execution:document.querySelector('#playground .execution-panel').getBoundingClientRect().width})"); - assert.ok(Math.abs(widths.source-widths.execution)<4,'source uses the full program width'); return layout.height; - }; - assert.equal(await evaluate("document.getElementById('source-section').open"),true); - assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),false); - assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source and editing'); - assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Read and edit source'); - assert.equal(await evaluate("document.getElementById('source-availability').hidden"),true); - await assertSourceFill(); - assert.deepEqual(await evaluate("({ordered:document.getElementById('source-section').compareDocumentPosition(document.getElementById('try-program'))===Node.DOCUMENT_POSITION_FOLLOWING,traceInside:!!document.querySelector('#try-program .trace-panel'),compileInside:!!document.querySelector('#source-section #compile'),bundleVisible:!!document.querySelector('section.bundle-details'),statusRemoved:!document.getElementById('program-selection-status')})"),{ordered:true,traceInside:true,compileInside:true,bundleVisible:true,statusRemoved:true}); - await evaluate("document.querySelector('#source-section>summary').click();document.querySelector('#try-program>summary').click()"); - assert.equal(await evaluate("document.getElementById('source-section').open"),false); - assert.equal(await evaluate("document.getElementById('try-program').open"),false); - await click('tab-export-definition'); - assert.ok(await evaluate("document.getElementById('package-info').getBoundingClientRect().height>0"),'bundle details stay visible when source and Try are collapsed'); - await click('tab-playground'); - await evaluate("document.querySelector('#source-section>summary').click()"); - await click('try-program-link'); - assert.equal(await evaluate("document.getElementById('try-program').open"),true,'source footer reveals Try before jumping to Run'); - console.log('PASS source-first ordering / independent disclosures / visible provenance / nested trace / source-footer compile'); - - const resultRecord=async()=>{ - const value=await evaluate("document.getElementById('result').textContent"); - assert.ok(value.startsWith('Returned record: '));return JSON.parse(value.slice('Returned record: '.length)); - }; - await evaluate("document.getElementById('program-picker').value='demo:parser';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source (read-only)'); - assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Read-only source'); - assert.equal(await evaluate("document.getElementById('edit-source')"),null); - assert.equal(await evaluate("document.getElementById('compile').hidden"),true); - assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true); - assert.match(await evaluate("document.getElementById('source').textContent"),/parserSepBy1/); - assert.match(await evaluate("document.getElementById('source-availability').textContent"),/reference only/); - await click('run');await wait(status('Finished')); - assert.deepEqual(await resultRecord(),{status:'ok',names:'red, green, blue',count:3,remaining:'',consumed:true,committed:true}); - for(const [definition,input,expected] of [ - ['parseNames','red,,blue',{status:'error',expected:'lowercase name',remaining:',blue',consumed:true,committed:true}], - ['parseNames','red,',{status:'error',expected:'lowercase name',remaining:'',consumed:true,committed:true}], - ['parsePrefix','red green',{status:'ok',names:'red',count:1,remaining:' green',consumed:true,committed:false}], - ]){ - await evaluate(`(()=>{document.getElementById('export').value=${JSON.stringify(definition)};document.getElementById('export').dispatchEvent(new Event('change'));const field=document.querySelector('#argument-fields textarea');field.value=${JSON.stringify(JSON.stringify(input))};field.dispatchEvent(new Event('input'));})()`); - await click('run');await wait(status('Finished'));assert.deepEqual(await resultRecord(),expected); - } - await evaluate("document.getElementById('program-picker').value='package:arboricx-server';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - await click('load-release');await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/native sockets and filesystem/); - await evaluate("document.getElementById('export').value='arboricxServer';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value=JSON.stringify([{type:'string',value:'/unused'},{type:'string',value:'127.0.0.1'},{type:'number',value:'9050'}]);document.getElementById('mode').value='io'"); - await click('run');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/^Program execution failed: Invalid canonical number/,'native action exports are not standalone browser tricuIO programs'); - console.log('PASS parser demo / complete vs prefix parsing / committed failures / read-only source / native server action is not executable as browser IO'); - await evaluate("document.getElementById('program-picker').value='demo:lexer';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source (read-only)'); - assert.equal(await evaluate("document.getElementById('compile').hidden"),true); - await click('run');await wait(status('Finished')); - assert.deepEqual(await resultRecord(),{matched:'hello',remaining:',world',line:1,column:6,offset:5,consumed:true}); - await evaluate("document.getElementById('export').value='scanWhitespace';document.getElementById('export').dispatchEvent(new Event('change'))"); - assert.deepEqual(await evaluate("[...document.querySelectorAll('#argument-fields label')].map(l=>l.textContent)"),['Text with leading whitespace']); - await click('run');await wait(status('Finished')); - assert.deepEqual(await resultRecord(),{matched:' \n\t',remaining:'word',line:2,column:2,offset:3,consumed:true}); - await evaluate("document.getElementById('program-picker').value='package:tricu-math';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - await click('load-release');await wait(status('Ready')); - await evaluate("document.getElementById('export').value='add';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value=JSON.stringify([{type:'number',value:'123456789'},{type:'number',value:'11'}]);document.getElementById('output-type').value='number'"); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "123456800"'); - await evaluate("document.getElementById('program-picker').value='package:tricu-compiler';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.5';document.getElementById('package-version').dispatchEvent(new Event('change'))"); - await click('load-release');await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); - assert.equal(await evaluate("document.getElementById('edit-source')"),null); - assert.equal(await evaluate("document.getElementById('export').options.length"),1191); - assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(compilerDescriptor.hash)); - const indexedCompilerRequests=requests.filter(p=>p.endsWith('/dist/compiler-worker.js')).length; - await click('tab-export-definition'); - await evaluate("document.getElementById('tree-definition').value='source_unit.compileStableModuleDefinitions';document.getElementById('tree-definition').dispatchEvent(new Event('change'));document.getElementById('tree-format').value='dag';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); - await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/source_unit.compileStableModuleDefinitions/); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),new RegExp(compilerDescriptor.hash)); - assert.equal(requests.filter(p=>p.endsWith('/dist/compiler-worker.js')).length,indexedCompilerRequests,'loading/exporting the compiler library never creates a draft compiler Worker'); - await click('tab-playground'); - // Hash loading/sharing must recognize the indexed compiler too, not only its name. - await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(compilerDescriptor.hash)};document.getElementById('program-hash').dispatchEvent(new Event('submit',{cancelable:true}))`);await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('export').options.length"),1191); - await evaluate("document.getElementById('program-picker').value='package:tricu-source-builder';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - await click('load-release');await wait(status('Ready')); - assert.deepEqual(await evaluate("[...document.getElementById('export').options].map(o=>o.value)"),['sourceBuildGraph']); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/native application compiler/); - for(const [name,expected] of [['boundary','Ready'],['oversized','Stopped']]){ - await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(published[name])};document.getElementById('program-hash').dispatchEvent(new Event('submit',{cancelable:true}))`);await wait(status(expected)); - if(name==='boundary'){ - assert.equal(await evaluate("document.getElementById('export').options.length"),1); - assert.equal(await evaluate("document.getElementById('export').value"),'root'); - }else assert.match(await evaluate("document.getElementById('result').textContent"),/download limit/); - } - console.log('PASS 16 MiB package boundary / over-limit rejection without execution settings'); - console.log('PASS lexer positions / pure arithmetic / indexed compiler exact pin and DAG export / compiler hash loading / native builder identity'); - for(const [demo,exports] of [['shipping',['v1','v2']],['events',['vendorA','vendorB']],['registration',['main']],['migration',['v1','v2']]]){ - await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-section').open"),true,'every selected demo starts with readable source'); - await evaluate("document.getElementById('source-section').open=true");await assertSourceFill(); - if(demo==='shipping')assert.deepEqual(await evaluate("[...document.querySelectorAll('#argument-fields label')].map(l=>l.textContent)"),['Destination','Cart subtotal (cents)','Package weight (grams)']); - const hash=catalogue.find(e=>e.id===demo).hash; - assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(hash)); - const results=[]; - for(const name of exports){ - await evaluate(`document.getElementById('export').value='${name}';document.getElementById('export').dispatchEvent(new Event('change'))`); - assert.equal(await evaluate("document.getElementById('output-type').value"),'record'); - await click('run');await wait(status('Finished'));results.push(await resultRecord()); - } - if(demo==='shipping'){assert.equal(results[0].shippingCents,0);assert.equal(results[1].shippingCents,849);} - if(demo==='events'){assert.deepEqual(results[0],results[1]);assert.equal(results[0].eventType,'order.created');} - if(demo==='migration'){assert.equal(results[0].schemaVersion,1);assert.equal(results[1].schemaVersion,2);assert.equal(results[1].marketingOptIn,false);} - if(demo==='registration'){ - assert.equal(results[0].accepted,true); - await evaluate("document.getElementById('arguments').value='[{\"type\":\"string\",\"value\":\"\"},{\"type\":\"string\",\"value\":\"bad\"},{\"type\":\"number\",\"value\":\"17\"},{\"type\":\"bool\",\"value\":false}]'"); - await click('run');await wait(status('Finished'));const bad=await resultRecord();assert.equal(bad.accepted,false);assert.equal(bad.nameError,'Name is required.');assert.equal(bad.ageError,'Age must be from 18 to 120.'); - } - } - console.log('PASS practical record demos / authenticated hashes / policy and schema versions / equivalent adapters / field diagnostics'); - - await evaluate("document.getElementById('program-picker').value='demo:microscope';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - const compilerGate=await evaluate(`new Promise((resolve,reject)=>{ - const w=new Worker(new URL('./dist/compiler-worker.js',location.href),{type:'module'}); - let compilation;const timer=setTimeout(()=>{w.terminate();reject(Error('compiler gate timeout'));},8000); - w.onerror=e=>{clearTimeout(timer);w.terminate();reject(Error(e.message));}; - w.onmessage=({data})=>{ - if(data.type==='compiled'){compilation=data;w.postMessage({type:'run',export:'id',arguments:[{type:'number',value:'42'}],output:'number',mode:'pure',step:false});} - if(data.type==='done'){clearTimeout(timer);w.terminate();resolve({compilation,result:data.result,uncapped:data.stats.fuel===Infinity});} - if(data.type==='error'){clearTimeout(timer);w.terminate();reject(Error(data.message));} - }; - w.postMessage({type:'compile',source:'id = (x : x)'}); - })`); - assert.deepEqual(compilerGate.compilation.names,['id']);assert.equal(compilerGate.result.value,'42');assert.equal(compilerGate.uncapped,true); - assert.ok(compilerGate.compilation.stats.collections>0); - assert.ok(compilerGate.compilation.stats.peakNodes<600000); - assert.ok(compilerGate.compilation.stats.nodes<100000); - console.log('PASS FIRST COMPILER GATE: Chromium compile→same-heap identity(42)',Math.round(compilerGate.compilation.milliseconds)+'ms',compilerGate.compilation.stats); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "5"'); - await evaluate("document.getElementById('export').value='same';document.getElementById('export').dispatchEvent(new Event('change'))"); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: true'); - console.log('PASS curated microscope / multiple exports / repeated runs'); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: true'); - const unlimitedPackage=await evaluate(`new Promise((resolve,reject)=>{ - const w=new Worker(new URL('./dist/worker.js',location.href),{type:'module'}); - w.onmessage=({data})=>{ - if(data.type==='loaded')w.postMessage({type:'run',export:'id',arguments:[{type:'number',value:'42'}],output:'number',mode:'pure',step:false}); - if(data.type==='done'){w.terminate();resolve({result:data.result,fuel:data.stats.fuel===Infinity,collections:data.stats.collections});} - if(data.type==='error'){w.terminate();reject(Error(data.message));} - }; - w.postMessage({type:'load',hash:${JSON.stringify(published.id)}}); - })`); - assert.equal(unlimitedPackage.result.value,'42');assert.equal(unlimitedPackage.fuel,true);assert.ok(unlimitedPackage.collections>0); - console.log('PASS package Worker has no application ceilings / GC remains active'); - - - - await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - await click('run');await wait(status('Finished')); - assert.match(await evaluate("document.getElementById('result').textContent"),/RGB triple accepted/); - await evaluate(`document.getElementById('arguments').value=${JSON.stringify(JSON.stringify([{type:'list',value:[{type:'number',value:'0'},{type:'number',value:'256'},{type:'number',value:'255'}]}]))}`); - await click('run');await wait(status('Finished')); - assert.match(await evaluate("document.getElementById('result').textContent"),/not a canonical byte/); - console.log('PASS tricu RGB validation / failing literal contract'); - - await evaluate("document.getElementById('program-picker').value='demo:greet';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - await click('run'); await wait(status('Waiting for input')); - await evaluate(`document.getElementById('line-input').value = 'λ雪🙂'; document.getElementById('input-form').requestSubmit()`); - await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('terminal').textContent"), 'What is your name?\nHello, λ雪🙂!\n'); - console.log('PASS packaged greeting / Unicode / continuation handles'); - await click('run');await wait(status('Waiting for input')); - await evaluate("document.getElementById('line-input').value='λ雪🙂';document.getElementById('input-form').requestSubmit()");await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('terminal').textContent"),'What is your name?\nHello, λ雪🙂!\n'); - await click('run');await wait(status('Waiting for input'));await click('tab-how'); - assert.equal(await evaluate("document.getElementById('input-form').hidden"),true); - await click('tab-playground'); - console.log('PASS unlimited package owned-IO continuations / Unicode / switching cancels pending input'); - - - await evaluate("document.getElementById('step-mode').checked = true"); - await click('run'); await wait(status('Paused before action')); - assert.equal(await evaluate("document.getElementById('terminal').textContent"), ''); - await click('next');await wait("document.querySelectorAll('#trace li').length === 2 && " + status('Paused before action')); - assert.equal(await evaluate("document.getElementById('terminal').textContent"), ''); - await click('next');await wait("document.getElementById('terminal').textContent.length > 0"); - await click('stop');await wait(status('Stopped'));await click('reset');await wait(status('Ready')); - assert.equal(await evaluate("document.querySelectorAll('#trace li').length"),0); - await evaluate("document.getElementById('step-mode').checked = false"); - await click('run');await wait(status('Waiting for input')); - await evaluate("document.getElementById('line-input').value='x'.repeat(257);document.getElementById('input-form').requestSubmit()"); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Waiting for input'); - assert.match(await evaluate("document.getElementById('result').textContent"),/limit/); - const attack='<img src=x onerror=alert(1)>'; - await evaluate(`document.getElementById('line-input').value=${JSON.stringify(attack)};document.getElementById('input-form').requestSubmit()`); - await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('terminal').textContent"),`What is your name?\nHello, ${attack}!\n`); - assert.equal(await evaluate("document.querySelectorAll('#terminal img,#trace img,#result img').length"),0); - console.log('PASS stepping / Stop / reset / bounded and HTML-like input'); - - await click('run');await wait(status('Waiting for input')); - await evaluate("document.getElementById('program-picker').value='demo:microscope';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('input-form').hidden"),true); - console.log('PASS run replacement discards pending input'); - - const loadHash=async hash=>{ - await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(hash)};document.getElementById('program-hash').requestSubmit()`); - await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'hash'); - assert.equal(await evaluate("document.getElementById('program-hash').hidden"),false); - assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp('SHA-256 verified.*'+hash)); - }; - await loadHash(published.append); - assert.equal(await evaluate("document.getElementById('mode').value"),'pure'); - assert.equal(await evaluate("document.getElementById('output-type').value"),'tree'); - await evaluate(`document.getElementById('arguments').value=${JSON.stringify(JSON.stringify([{type:'string',value:'hello '},{type:'string',value:'registry'}]))};document.getElementById('output-type').value='string'`); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "hello registry"'); - console.log('PASS arbitrary registry bytes / literal arguments / explicit output codec'); - await loadHash(published.greet);await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('terminal').textContent"),''); - assert.equal(await evaluate("document.getElementById('input-form').hidden"),true); - assert.match(await evaluate("document.getElementById('result').textContent"),/Returned tree/); - console.log('PASS pure mode never executes an IO-shaped return'); - - - await loadHash(published.preview); - await evaluate("document.getElementById('tree-format').value='ternary';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); - await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); - assert.equal((await evaluate("document.getElementById('tree-preview').textContent")).length,16384); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/Preview: first 16,384.*complete tree/); - await evaluate("navigator.clipboard.writeText=async text=>{window.__largeCopy=text;}"); - await click('copy-tree');await wait("typeof window.__largeCopy==='string'"); - assert.equal((await evaluate('window.__largeCopy')).length,32767); - await evaluate("navigator.clipboard.writeText=window.__originalClipboard;document.getElementById('bundle-hash').dispatchEvent(new Event('input'))"); - assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),true); - assert.equal(await evaluate("document.getElementById('tree-preview').hidden"),true); - console.log('PASS partial preview explicitly labeled / complete large copy / edited hash invalidation'); - await loadHash(published.larger); - await evaluate("document.getElementById('tree-format').value='ternary';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); - await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/4,194,303 UTF-8 bytes/); - assert.equal(await evaluate("document.getElementById('tree-preview').textContent.length"),16384); - await evaluate("navigator.clipboard.writeText=async text=>{window.__largerTree=text;}"); - await click('copy-tree');await wait("typeof window.__largerTree==='string'"); - assert.equal(await evaluate("window.__largerTree.length"),2**22-1); - await evaluate("navigator.clipboard.writeText=window.__originalClipboard;delete window.__largerTree"); - console.log('PASS expanded export beyond former 2 MiB cap / bounded preview / complete copied output'); - await loadHash(published.huge); - await evaluate("document.getElementById('tree-format').value='ternary';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); - await click('export-tree');await wait("document.getElementById('tree-provenance').textContent.includes('Choose DAG')"); - assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),true); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await evaluate("document.getElementById('tree-format').value='dag';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); - const fetchesBeforeExport=requests.filter(path=>path.includes('/bundle/hash/')).length; - await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); - assert.ok((await evaluate("document.getElementById('tree-preview').textContent")).length<3000); - assert.equal(requests.filter(path=>path.includes('/bundle/hash/')).length,fetchesBeforeExport); - await click('run');await wait(status('Finished')); - console.log('PASS registry-selected shared tree / bounded flat export error / DAG recovery without refetch / Run after export'); - - const preview=JSON.parse(await evaluate("document.getElementById('graph-result').textContent")); - assert.equal(preview.nodes.length,32); - assert.match(await evaluate("document.getElementById('result').textContent"),/32 heap nodes/); - await loadHash(published.lazy); - await evaluate(`document.getElementById('export').value='f';document.getElementById('arguments').value='[{"type":"tree","value":"212110021100"}]';document.getElementById('output-type').value='bool'`); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: false'); - await evaluate("document.getElementById('export').value='q'");await click('run');await wait(status('Running')); - await new Promise(resolve=>setTimeout(resolve,100)); - assert.equal(await evaluate("document.getElementById('stop').disabled"),false); - await click('stop');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/Stopped/); - console.log('PASS exponential DAG / lazy discarded divergence / hard Stop of actual divergent program'); - - for(const [hash,expected] of [['f'.repeat(64),/SHA-256 mismatch/],['a'.repeat(64),/download limit/],[published.invalid,/reserved/]]){ - await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(hash)};document.getElementById('program-hash').requestSubmit()`); - await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),expected); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - } - console.log('PASS corrupt hash / oversized download / malformed bundle fail closed'); - await navigate('Page.navigate',{url:url+'#bundle='+published.id+'&export=id'});await openPlayground(); - assert.equal(await evaluate("document.getElementById('export').value"),'id'); - assert.equal(await evaluate("document.getElementById('terminal').textContent"),''); - console.log('PASS share selection loads metadata without executing'); - - await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'"); - await click('load-release');await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'library release is not a curated editable demo'); - assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); - assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Compiled package (no editable source)'); - assert.equal(await evaluate("document.getElementById('edit-source')"),null); - assert.equal(await evaluate("document.getElementById('source-availability').hidden"),false); - assert.match(await evaluate("document.getElementById('source-availability').textContent"),/Editable source is not included/); - await evaluate("document.getElementById('export').value='showNumber';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]';document.getElementById('output-type').value='string';document.getElementById('mode').value='pure'"); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "42"'); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/tricu-base@0.1.0/); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'package:tricu-base'); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/tricu-base@0.1.0/); - await evaluate("document.getElementById('package-version').value='0.1.1';document.getElementById('package-version').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - await click('load-release');await wait(status('Ready')); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/tricu-base@0.1.1/); - console.log('PASS published library selection / verified native library running in Chromium / no fabricated editable source'); - - await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - // The same controls now serve published releases and explicitly local drafts. - await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'"); - await click('load-release');await wait(status('Ready')); - assert.match(await evaluate("document.getElementById('release-info').textContent"),/intro@0.1.0/); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); - const publishedSource=await evaluate("document.getElementById('source-editor').value"); - await evaluate(`window.__createURL=URL.createObjectURL;URL.createObjectURL=blob=>{window.__bundleBytes=blob.arrayBuffer().then(b=>Array.from(new Uint8Array(b)));return window.__createURL(blob);}; - window.__bundleClick=e=>{if(e.target instanceof HTMLAnchorElement && e.target.download){e.preventDefault();window.__bundleName=e.target.download;}};document.addEventListener('click',window.__bundleClick);`); - await click('download-bundle');await wait("document.getElementById('result').textContent.includes('Downloaded original verified bundle')"); - const downloaded=Buffer.from(await evaluate('window.__bundleBytes')); - assert.equal(pin(downloaded),catalogue.find(e=>e.id==='intro').hash); - assert.equal(await evaluate('window.__bundleName'),pin(downloaded)+'.arboricx'); - for(const pane of ['tab-playground','tab-export-definition']){ - await click(pane); - await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - await evaluate("document.getElementById('package-version').value='0.1.0';document.getElementById('package-version').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false,'pending exact version downloads without Load'); - assert.equal(await evaluate("document.getElementById('download-bundle').closest('[role=tabpanel]').id"),pane==='tab-playground'?'playground':'export-definition'); - assert.equal(await evaluate("document.getElementById('download-bundle').closest('details')"),null); - const beforeCompile=requests.filter(p=>p.endsWith('/compiler.json')).length; - await click('download-bundle');await wait("document.getElementById('bundle-download-status').textContent.startsWith('Downloaded original verified bundle')"); - const expected=packageIndex.releases.find(r=>r.name==='rgb'&&r.version==='0.1.0').hash; - assert.equal(pin(Buffer.from(await evaluate('window.__bundleBytes'))),expected); - assert.equal(await evaluate("document.getElementById('run').disabled"),true,'download does not load an executable'); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - assert.equal(requests.filter(p=>p.endsWith('/compiler.json')).length,beforeCompile); - } - await click('tab-playground'); - await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(published.id)};document.getElementById('bundle-hash').dispatchEvent(new Event('input'))`); - assert.equal(await evaluate("document.getElementById('program-release').hidden"),true); - assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false); - await click('download-bundle');await wait("document.getElementById('bundle-download-status').textContent.startsWith('Downloaded original verified bundle')"); - assert.equal(pin(Buffer.from(await evaluate('window.__bundleBytes'))),published.id); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - for(const id of ['parser','lexer']){ - await evaluate(`document.getElementById('program-picker').value='demo:${id}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); - assert.equal(await evaluate("document.getElementById('package-version').disabled"),false); - } - // Return to the original editable selection for the draft-isolation controls. - await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - console.log('PASS prominent shared download toolbar / all demo versions visible / selected version and direct hash download without Load / no implicit execution'); - await evaluate("URL.createObjectURL=window.__createURL;document.removeEventListener('click',window.__bundleClick)"); - assert.equal(await evaluate("document.getElementById('edit-source')"),null); - assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); - assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),false); - assert.equal(await evaluate("document.getElementById('run').textContent"),'Run published','directly editable source does not replace the executable'); - const beforeDraftEdit=requests.filter(p=>p.endsWith('/compiler.json')).length; - await evaluate("document.getElementById('source-editor').value=document.getElementById('source-editor').value.replace('first 42 7','first 43 7');document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - assert.match(await evaluate("document.getElementById('authorship').textContent"),/Local draft/); - assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),true); - assert.equal(await evaluate("document.getElementById('share').disabled"),true); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - assert.equal(await evaluate("document.querySelector('dialog')"),null); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - assert.equal(await evaluate("document.getElementById('compile').disabled"),false); - await evaluate("document.getElementById('run').onclick()"); - assert.equal(requests.filter(p=>p.endsWith('/compiler.json')).length,beforeDraftEdit,'Run cannot compile an uncompiled draft'); - assert.equal(await evaluate("document.getElementById('compiler-disclosure')"),null); - assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),true); - await click('compile');await wait(status('Compiled')+' || '+status('Stopped'),60); - assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); - await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); - console.log('PASS curated intro edit with explicit empty environment',await evaluate("document.getElementById('compiler-stats').textContent")); - // Edits keep the last successful definitions runnable until a compilation - // fails. Failed compilation must never fall back to the earlier 43 or published 42. - await evaluate("document.getElementById('source-editor').value='answer = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),true); - assert.equal(await evaluate("document.getElementById('export').disabled"),false); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); - await click('compile');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic/); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - await click('restore-source');await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('source-editor').value"),publishedSource); - assert.match(await evaluate("document.getElementById('authorship').textContent"),/Published/); - assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - // All practical demos use a prebaked support package, not browser imports. - for(const demo of ['shipping','events','registration','migration']) { - await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); - await evaluate("document.getElementById('source-editor').value='answer = stringField \"status\" \"edited\"';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]';document.getElementById('output-type').value='list'"); - await click('compile');await wait(status('Compiled')+' || '+status('Stopped'),60); - assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); - await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); - const result=await evaluate("document.getElementById('result').textContent"); - assert.match(result,/^Returned list:/,demo+': '+result); - console.log('PASS editable '+demo,await evaluate("document.getElementById('compiler-stats').textContent")); - await click('restore-source');await wait(status('Ready')); - } - await loadHash(published.id); - assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true); - assert.equal(await evaluate("document.getElementById('compile').disabled"),true); - assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false); - console.log('PASS named releases / exact bundle download / local draft invalidation / failure without fallback / restore / arbitrary read-only packages'); - await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - // Compiler UI: checkpoint transport preserves lazy DAGs, never flat source. - const compilerStatus=text=>`document.getElementById('status').textContent===${JSON.stringify(text)}`; - await click('compile');await wait(compilerStatus('Compiled')); - assert.equal(await evaluate("document.getElementById('export').value"),'id'); - // Bad arguments leave the costly compiled heap available for correction. - await evaluate("document.getElementById('arguments').value='invalid JSON'");await click('run'); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Compiled'); - await evaluate("document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'"); - await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - const compiledRequests=requests.filter(path=>path.endsWith('/compiler.json')).length; - // Output interpretation errors discard only the application, not compilation. - await evaluate("document.getElementById('output-type').value='string'"); - await click('run');await wait(compilerStatus('Stopped')); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await evaluate("document.getElementById('output-type').value='number'"); - for(const action of ['retry','reset','stop']){ - if(action==='reset')await click('reset'); - if(action==='stop')await click('stop'); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - } - // Hard Stop during a stalled run must retain runnable definitions. - await evaluate(`globalThis.savedDraftPost=Worker.prototype.postMessage; - Worker.prototype.postMessage=function(message,...args){if(message.type!=='run')return globalThis.savedDraftPost.call(this,message,...args);}`); - await click('run');await wait(compilerStatus('Running'));await click('stop'); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await evaluate("Worker.prototype.postMessage=globalThis.savedDraftPost;delete globalThis.savedDraftPost"); - for(const value of ['7','99']){ - await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`); - await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned number: "${value}"`); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - } - assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compiledRequests); - await evaluate("document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'"); - assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/work units/); - console.log('PASS compiler without resource settings / corrected arguments / repeated values without recompile / Worker disposal'); - assert.equal(await evaluate("document.getElementById('source-editor').maxLength"),32768); - await evaluate("document.getElementById('source-editor').value='--'+ 'x'.repeat(300)+'\\nid = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - await click('compile');await wait(compilerStatus('Compiled'),60); - await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - console.log('PASS source beyond former 256 limit / no main-thread graph encoding cap'); - - for(const example of ['pair','definitions']){ - await evaluate(`document.querySelector('[data-compiler-example=${example}]').click()`); - assert.equal(await evaluate("document.getElementById('run').disabled"),false,'scratch source presets retain the last compilation'); - await click('compile');await wait(compilerStatus('Compiled')); - assert.equal(await evaluate("document.getElementById('export').value"),example==='pair'?'pair':'answer'); - if(example==='definitions')assert.equal(await evaluate("document.getElementById('export').options.length"),2); - await click('run');await wait(compilerStatus('Finished')); - if(example==='definitions')assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - else assert.match(await evaluate("document.getElementById('result').textContent"),/Returned tree/); - } - console.log('PASS richer compiler examples / definition dependencies / selected compiled exports'); - await evaluate("document.querySelector('[data-compiler-example=contracts]').click()"); - await click('compile');await wait(compilerStatus('Compiled'));await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - let previousTotal=0; - for(const value of ['12','13']){ - await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`); - await click('run');await wait(compilerStatus('Finished')); - const stats=await evaluate("document.getElementById('compiler-stats').textContent"); - const total=Number(stats.match(/([0-9,]+) cumulative work/)[1].replaceAll(',','')); - assert.ok(total>previousTotal);previousTotal=total; - assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned number: "${value}"`); - } - console.log('PASS cumulative compile/run work counters without ceilings'); - await evaluate("document.getElementById('arguments').value='[{\"type\":\"tree\",\"value\":\"10\"}]';document.getElementById('output-type').value='tree'"); - await click('run');await wait(compilerStatus('Finished')); - assert.match(await evaluate("document.getElementById('graph-result').textContent"),/fork/); - await evaluate("document.getElementById('source-editor').value='id =@nat? (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]'"); - await click('compile');await wait(compilerStatus('Compiled'));await click('run');await wait(compilerStatus('Finished')); - assert.match(await evaluate("document.getElementById('graph-result').textContent"),/fork/); - const rejectedFunction=JSON.parse(await evaluate("document.getElementById('graph-result').textContent")); - assert.equal(rejectedFunction.nodes.find(n=>n.id===rejectedFunction.root).children[0],0,'function tree must fail the canonical-natural contract'); - console.log('PASS default bare contract sugar / checked identity / error Result / rejected function-value annotation'); - - await evaluate(`document.getElementById('source-editor').value='id = (x : x)\\nanswer = id 42\\nother = id 7\\nmore = id 9\\nextra = id 11\\nlast = id 13';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]';document.getElementById('output-type').value='number'`); - await click('compile');await wait(compilerStatus('Compiled')); - await evaluate("document.getElementById('export').value='other'");await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "7"'); - const allocationText=await evaluate("document.getElementById('compiler-stats').textContent"); - const allocated=allocationText.match(/([0-9,]+) allocated/); - assert.ok(allocated);assert.ok(Number(allocated[1].replaceAll(',',''))>6_000_000); - await evaluate("document.getElementById('export').value='answer'");await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - await evaluate("document.getElementById('export').value='id';document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"13\"}]'");await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "13"'); - console.log('PASS GC compiles/runs beyond the old 6m cumulative-allocation limit'); - await evaluate("document.querySelector('[data-compiler-example=input]').click()"); - await click('compile');await wait(compilerStatus('Compiled')); - await evaluate("document.getElementById('step-mode').checked=true");await click('run'); - await wait(compilerStatus('Paused before action'));await click('next');await wait(compilerStatus('Waiting for input')); - await evaluate("document.getElementById('line-input').value='λ雪🙂';document.getElementById('input-form').requestSubmit()"); - await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "λ雪🙂"'); - await evaluate("document.getElementById('step-mode').checked=false"); - for(const value of ['second','third']){ - await click('run');await wait(compilerStatus('Waiting for input')); - await evaluate(`document.getElementById('line-input').value='${value}';document.getElementById('input-form').requestSubmit()`); - await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned string: "${value}"`); - } - const beforeRestart=requests.filter(path=>path.endsWith('/compiler.json')).length; - await click('run');await wait(compilerStatus('Waiting for input')); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await click('run');await wait(compilerStatus('Waiting for input')); - await click('tab-how');await click('tab-playground'); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await click('run');await wait(compilerStatus('Waiting for input')); - await evaluate("document.getElementById('line-input').value='after cancel';document.getElementById('input-form').requestSubmit()"); - await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "after cancel"'); - assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeRestart); - console.log('PASS repeated compiled IO / fresh input / stepping / retained definition roots'); - await evaluate("document.getElementById('source-editor').value='id = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - await click('compile');await wait(compilerStatus('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic 2.*expected token kind 7/); - assert.equal(await evaluate("document.getElementById('export').disabled"),true); - await evaluate("document.getElementById('source-editor').value='id = nope'"); - await click('compile');await wait(compilerStatus('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic 20 at 1:6.*unresolved name.*nope/); - for(const [fault,expected] of [['descriptor',/stable compiler descriptor/],['pin',/SHA-256 mismatch/],['oversize',/download limit/]]){ - compilerFault=fault;await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - await click('compile');await wait(compilerStatus('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),expected); - assert.equal(await evaluate("document.getElementById('export').disabled"),true); - assert.match(await evaluate("document.getElementById('run').textContent"),/Run draft/); - } - compilerFault=undefined; - // Source bound is enforced before the compiler bundle is downloaded again. - const before=requests.filter(path=>path.endsWith('/compiler.json')).length; - await evaluate("document.getElementById('source-editor').value='x'.repeat(16385)");await click('compile'); - assert.match(await evaluate("document.getElementById('result').textContent"),/limit/); - assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,before); - await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - await click('compile');await wait(compilerStatus('Compiled')); - await evaluate("document.getElementById('program-picker').value='demo:microscope';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('authorship').textContent.startsWith('Published')"),true); - await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - await click('compile');await click('stop');await wait(compilerStatus('Stopped')); - await new Promise(resolve=>setTimeout(resolve,500)); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped'); - await click('reset'); - assert.equal(await evaluate("document.getElementById('compile').disabled"),false); - console.log('PASS compiler syntax/name diagnostics / descriptor and pin integrity / source cap / replacement / stop/reset recovery'); - - // Stall real operation messages and advance the clock only at its scheduled - // 60-second boundary, avoiding a minute of test time per load/download case. - const loadTimeoutScript=await send('Page.addScriptToEvaluateOnNewDocument',{source:` - const NativeWorker=globalThis.Worker,nativeTimer=globalThis.setTimeout,nativeNow=performance.now.bind(performance); - let elapsed=0; - Object.defineProperty(performance,'now',{value:()=>nativeNow()+elapsed}); - globalThis.setTimeout=(fn,delay,...args)=>globalThis.timeoutOperation&&delay===60000 - ? nativeTimer(()=>{elapsed+=60000;fn(...args);},50):nativeTimer(fn,delay,...args); - globalThis.Worker=class extends NativeWorker{ - postMessage(message){if(message.type===globalThis.timeoutOperation){this.stalled=true;return;}super.postMessage(message);} - terminate(){if(this.stalled)globalThis.stalledWorkerTerminated=true;super.terminate();} - };`}); - await navigate('Page.reload');await openPlayground(); - for(const pane of ['tab-playground','tab-export-definition']){ - await click(pane); - await evaluate("globalThis.timeoutOperation='load';globalThis.stalledWorkerTerminated=false;document.getElementById('program-picker').value='demo:greet';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - await wait(status('Stopped')); - const target=pane==='tab-playground'?'result':'tree-provenance'; - assert.match(await evaluate(`document.getElementById('${target}').textContent`),/^Package loading timed out\./); - assert.equal(await evaluate('globalThis.stalledWorkerTerminated'),true); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); - assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); - await evaluate("globalThis.timeoutOperation=undefined;document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - } - await click('tab-playground'); - await evaluate("globalThis.timeoutOperation='download';globalThis.stalledWorkerTerminated=false"); - await click('download-bundle');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/^Bundle download timed out\./); - assert.equal(await evaluate('globalThis.stalledWorkerTerminated'),true); - await send('Page.removeScriptToEvaluateOnNewDocument',{identifier:loadTimeoutScript.identifier}); - console.log('PASS loading deadlines in Playground/Export / download deadline / phase-specific messages / worker termination'); - - // A genuinely CPU-bound Worker stays running until Stop or pane cancellation. No production hook. - const watchdogScript = await send('Page.addScriptToEvaluateOnNewDocument', { source: - `const NativeWorker = globalThis.Worker; - globalThis.Worker = class extends NativeWorker { - postMessage(message) { - if(message.type === 'run' || message.type === 'export') this.spin = new NativeWorker(URL.createObjectURL(new Blob(['while (true) {}'], { type: 'text/javascript' }))); - else super.postMessage(message); - } - terminate() { this.spin?.terminate(); super.terminate(); globalThis.testWorkerTerminated = true; } - };` }); - await navigate('Page.reload'); await openPlayground(); - await click('run');await new Promise(resolve=>setTimeout(resolve,11_000)); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Running'); - await click('tab-how');assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped'); - assert.equal(await evaluate('globalThis.testWorkerTerminated'),true); - console.log('PASS package timer disabled / leaving pane cancels CPU-bound job'); - - await click('tab-export-definition'); - await click('export-tree'); - await new Promise(resolve=>setTimeout(resolve,11_000)); - assert.equal(await evaluate("document.getElementById('stop-tree').disabled"),false,'export remains active beyond old 10-second deadline'); - await wait("document.getElementById('tree-provenance').textContent.includes('Definition export stopped: active time limit exceeded')",115); - assert.equal(await evaluate('globalThis.testWorkerTerminated'),true); - assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),true); - await click('export-tree'); await click('stop-tree'); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/Stopped/); - assert.equal(await evaluate("document.getElementById('stop-tree').disabled"),true); - await click('export-tree'); await click('tab-how'); - await new Promise(resolve=>setTimeout(resolve,500)); - assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/Stopped/); - assert.equal(await evaluate("document.getElementById('tree-preview').hidden"),true); - console.log('PASS export watchdog independent of execution / visible errors / Stop / pane cancellation'); - - - // A CPU-bound compiler remains running past the former 30-second deadline; - // the main page stays responsive and Stop still terminates it immediately. - const compilerFaultScript=await send('Page.addScriptToEvaluateOnNewDocument',{source: - `const BaseWorker=globalThis.Worker;globalThis.Worker=class extends BaseWorker{ - postMessage(message){if(message.type==='compile')this.spin=new BaseWorker(URL.createObjectURL(new Blob(['while(true){}'],{type:'text/javascript'})));else super.postMessage(message);} - terminate(){this.spin?.terminate();super.terminate();globalThis.compilerWorkerTerminated=true;} - };`}); - await navigate('Page.reload');await openPlayground(); - await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - await click('compile'); - await new Promise(resolve=>setTimeout(resolve,31_000)); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Compiling'); - await evaluate("document.getElementById('try-program').open=false"); - assert.equal(await evaluate("document.getElementById('stop-compile').disabled"),false); - await click('stop-compile');await wait(compilerStatus('Stopped')); - assert.equal(await evaluate('globalThis.compilerWorkerTerminated'),true); - console.log('PASS no compiler timeout past 30 seconds / manual CPU-bound cancellation'); - await evaluate('globalThis.compilerWorkerTerminated=false');await click('compile');await click('tab-how'); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped'); - assert.equal(await evaluate('globalThis.compilerWorkerTerminated'),true); - console.log('PASS leaving compiler pane terminates active compilation'); - await send('Page.removeScriptToEvaluateOnNewDocument',{identifier:compilerFaultScript.identifier}); - - // Reload a real Worker page, check narrow layout and optionally save a screenshot. - await send('Page.removeScriptToEvaluateOnNewDocument', { identifier: watchdogScript.identifier }); - await navigate('Page.navigate', { url }); - await wait("typeof document.getElementById('tab-playground')?.onclick === 'function'"); - await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); - assert.equal(await evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true); - for(const id of ['tab-playground','tab-export-definition','tab-how']) { - await click(id); - await assertPaneFits(); - assert.equal(await evaluate('document.documentElement.scrollWidth <= window.innerWidth'),true); - } - console.log('PASS narrow viewport / dynamic pane layout / no horizontal page overflow'); - for(const width of [320,360,390,430,540,740,949,950,1440]) { - await send('Emulation.setDeviceMetricsOverride',{width,height:900,deviceScaleFactor:1,mobile:width<950}); - const layout=await evaluate(`(()=>{ - const r=e=>e.getBoundingClientRect(); - const shapes=[...document.querySelectorAll('.shapes svg')]; - const bounds=shapes.map(e=>r(e)); - const circles=shapes.map(e=>[...e.querySelectorAll('circle')].map(c=>r(c).y+r(c).height/2)); - const cta=r(document.querySelector('.hero > .button')), hero=r(document.querySelector('.hero')); - const nav=[...document.querySelectorAll('nav a')].map(e=>r(e)); - return {overflow:document.documentElement.scrollWidth>innerWidth, - center:cta.x+cta.width/2-(hero.x+hero.width/2), - leafOffset:(circles[0][0]-bounds[0].y)/bounds[0].height, - circles,bounds:bounds.map(b=>({x:b.x,right:b.right})),nav:nav.map(b=>({x:b.x,right:b.right,height:b.height})), - size:parseFloat(getComputedStyle(document.getElementById('source-editor')).fontSize), - shapePosition:getComputedStyle(document.querySelector('.shapes')).position}; - })()`); - assert.equal(layout.overflow,false,`overflow at ${width}px`); - await click('tab-playground'); - await evaluate("document.getElementById('source-section').open=true"); - if(width>740)await assertSourceFill(); - else assert.ok(await evaluate("document.getElementById('source-editor').getBoundingClientRect().height<=360"),'stacked mobile source stays bounded'); - assert.ok(Math.abs(layout.leafOffset-43.5/80)<0.01,`universal Leaf offset at ${width}px`); - for(const b of [...layout.bounds,...layout.nav]) assert.ok(b.x>=0&&b.right<=width,`offscreen element at ${width}px`); - if(width<950) { - assert.ok(Math.abs(layout.center)<1,`CTA centered at ${width}px`); - const leaf=layout.circles[0][0], stem=layout.circles[1], fork=layout.circles[2]; - assert.ok(Math.abs(leaf-(stem[0]+stem[1])/2)<1,`Leaf midpoint at ${width}px`); - assert.ok(Math.abs(leaf-(fork[0]+fork[1])/2)<1,`Fork midpoint at ${width}px`); - } else { - assert.equal(layout.shapePosition,'absolute');assert.ok(Math.abs(layout.center)>10,'desktop CTA stays left aligned'); - assert.ok(Math.abs(layout.circles[0][0]-layout.circles[1][0])>50,'desktop illustrations retain vertical arrangement'); - } - if(width<=740)assert.ok(layout.size>=16,'mobile inputs stay legible'); - if(width<=540)for(const item of layout.nav)assert.ok(item.height>=44,'mobile navigation tap target'); - for(const id of ['tab-playground','tab-export-definition','tab-how']) { - await click(id); - assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,`${id} at ${width}px`); - if(id!=='tab-how'){ - const button=await evaluate("(()=>{const b=document.getElementById('download-bundle'),r=b.getBoundingClientRect();return {width:r.width,height:r.height,left:r.left,right:r.right,hidden:!!b.closest('[hidden]'),details:!!b.closest('details')};})()"); - assert.ok(button.width>0&&button.height>=44&&!button.hidden&&!button.details,'download is outside disclosures'); - assert.ok(button.left>=0&&button.right<=width,'download CTA fits the viewport'); - } - } - } - for(const width of [320,390,1440]) { - await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950}); - await click('tab-export-definition'); - await evaluate("document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,`hash loader at ${width}px`); - if(width<950)assert.ok(await evaluate("parseFloat(getComputedStyle(document.getElementById('bundle-hash')).fontSize)>=16")); - if(process.argv[3] && width===390) { - const clip=await evaluate("(()=>{const r=document.querySelector('.workspace').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()"); - const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip}); - await writeFile(process.argv[3].replace(/\.png$/,'')+'-hash-390.png',Buffer.from(shot.data,'base64')); - } - await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); - assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,`package loader at ${width}px`); - } - await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); - await click('tab-how'); - const tabRects=await evaluate("[...document.querySelectorAll('[role=tab]')].map(t=>({top:t.getBoundingClientRect().top,width:t.getBoundingClientRect().width}))"); - assert.ok(tabRects.every(r=>Math.abs(r.top-tabRects[0].top)<1)); - assert.ok(tabRects.every(r=>Math.abs(r.width-tabRects[0].width)<1)); - console.log('PASS responsive 320-1440px / Leaf midpoint / centered mobile CTA / header targets / legible inputs / unchanged desktop arrangement'); - await send('Emulation.setDeviceMetricsOverride',{width:320,height:844,deviceScaleFactor:1,mobile:true}); - await click('tab-playground'); - await evaluate("document.querySelector('#playground .run-tools').open=true");await assertPaneFits(); - assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true); - assert.equal(await evaluate("document.querySelectorAll('.limits-editor,.execution-limits').length"),0); - await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});await click('tab-how'); - console.log('PASS narrow-screen Advanced without resource editors'); - - assert.equal(requests.some(path=>path.startsWith(prefix+'bundles/')),false,'no static bundle fallback'); - for(const entry of [...catalogue,compilerDescriptor])assert.ok(requests.includes('/_arboricx/bundle/hash/'+entry.hash),'dependency fetched from registry'); - // Publish a newer example without changing its catalogue source pin. The - // default must follow the release, while hash links still select exact bytes. - const intro=catalogue.find(e=>e.id==='intro'); - const future={...packageIndex.releases.find(r=>r.demo==='intro'),version:'0.10.0',hash:published.id}; - indexOverride={...packageIndex,releases:[future,...packageIndex.releases]}; - await evaluate("history.replaceState(null,'',location.pathname)"); - await navigate('Page.reload');await openPlayground(); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); - assert.equal(await evaluate("document.getElementById('package-version').value"),'0.10.0'); - assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(published.id)); - assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'newer bundle cannot borrow catalogue source'); - assert.equal(await evaluate("document.getElementById('arguments').value"),'[]','newer bundle cannot borrow catalogue inputs'); - await evaluate("document.getElementById('program-picker').value='demo:greet';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('package-version').value"),'0.10.0','reselecting example also defaults to latest'); - await evaluate(`history.replaceState(null,'',location.pathname+'#bundle=${intro.hash}&demo=intro')`); - await navigate('Page.reload');await openPlayground(); - assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.0','shared hash does not upgrade to latest'); - assert.equal(await evaluate("document.getElementById('source-editor').value"),intro.source); - indexOverride=undefined; - console.log('PASS newer demo release without matching catalogue / no borrowed source or inputs / exact older shared pin'); - assert.deepEqual(exceptions, []); - if (process.argv[3]) { - const screenshot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }); - await writeFile(process.argv[3], Buffer.from(screenshot.data, 'base64')); - await click('tab-playground');await evaluate("document.querySelector('#playground .run-tools').open=false"); - const clip=await evaluate("(()=>{const r=document.getElementById('playground').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()"); - const panel=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip}); - await writeFile(process.argv[3].replace(/\.png$/,'')+'-compiler.png',Buffer.from(panel.data,'base64')); - await click('tab-export-definition'); await wait(status('Ready')); - await evaluate("document.getElementById('tree-definition').value='identity';document.getElementById('tree-definition').dispatchEvent(new Event('change'))"); - await click('export-tree'); await wait("!document.getElementById('copy-tree').disabled"); - for (const width of [390, 1440]) { - await send('Emulation.setDeviceMetricsOverride',{width,height:1100,deviceScaleFactor:1,mobile:width<950}); - const clip=await evaluate("(()=>{const r=document.querySelector('.workspace').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()"); - const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip}); - await writeFile(process.argv[3].replace(/\.png$/,'')+'-export-'+width+'.png',Buffer.from(shot.data,'base64')); - } - } - // Even blocked browser storage and an older saved draft must be irrelevant. - const storageScript=await send('Page.addScriptToEvaluateOnNewDocument',{source:` - localStorage.setItem('arboricx.local-draft.v1',JSON.stringify({schema:1,source:'oldDraft = 999',name:'oldDraft',arguments:'[]',output:'number',mode:'pure'})); - globalThis.storageTouches=[]; - for(const key of ['localStorage','sessionStorage'])Object.defineProperty(globalThis,key,{configurable:true,get(){globalThis.storageTouches.push(key);throw new DOMException('blocked','SecurityError');}}); - globalThis.confirm=()=>{throw Error('Unexpected confirmation');};`}); - await evaluate("history.replaceState(null,'',location.pathname)"); - await navigate('Page.reload');await openPlayground(); - const original=await evaluate("document.getElementById('source-editor').value"); - assert.doesNotMatch(original,/oldDraft/); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); - assert.equal(await evaluate("document.querySelector('.draft-recovery,#recover-draft,#forget-draft,#download-draft-source')"),null); - await evaluate("document.querySelector('[data-compiler-example=identity]').click();document.getElementById('source-editor').value='id = (x : x)\\n-- page-local draft';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - await click('compile');await wait(compilerStatus('Compiled')); - assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/work units/); - const liveSource=await evaluate("document.getElementById('source-editor').value"); - await click('run');await wait(status('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - await click('stop-compile'); - assert.equal(await evaluate("document.getElementById('source-editor').value"),liveSource); - await evaluate("document.getElementById('source-editor').value='id = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - await click('compile');await wait(compilerStatus('Stopped')); - assert.equal(await evaluate("document.getElementById('source-editor').value"),'id = ('); - assert.deepEqual(await evaluate('globalThis.storageTouches'),[]); - const beforeReload=requests.filter(path=>path.endsWith('/compiler.json')).length; - await navigate('Page.reload');await openPlayground(); - assert.equal(await evaluate("document.getElementById('source-editor').value"),original,'reload starts with published source, not the failed or saved draft'); - assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeReload,'reload does not compile'); - assert.equal(await evaluate("document.getElementById('run').textContent"),'Run published'); - assert.equal(await evaluate("document.getElementById('result').textContent"),''); - assert.deepEqual(await evaluate('globalThis.storageTouches'),[]); - await evaluate(`history.replaceState(null,'',location.pathname+'#bundle=${published.greet}&demo=greet&export=main')`); - await navigate('Page.reload');await openPlayground(); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:greet','a shared published selection wins over obsolete stored drafts'); - assert.equal(await evaluate("document.getElementById('source-editor').value"),greeting.source); - assert.equal(await evaluate("document.getElementById('terminal').textContent"),''); - assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeReload); - assert.deepEqual(await evaluate('globalThis.storageTouches'),[]); - await send('Page.removeScriptToEvaluateOnNewDocument',{identifier:storageScript.identifier}); - console.log('PASS page-local drafts / no storage access or recovery UI / blocked storage / old drafts ignored / source retained after Stop and errors / reload starts fresh without execution'); - assert.deepEqual(exceptions, []); - -} finally { - for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); } - pending.clear(); socket?.close(); - if (browser && browser.exitCode === null) { - const exited = new Promise(resolve => browser.once('exit', resolve)); - browser.kill('SIGKILL'); await exited; - } - await new Promise(resolve => server.close(resolve)); - await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); -} +// Current full-page editor regression, dependency-free Chromium CDP harness. +// nix-built site required: ARBORICX_SITE_ROOT=$(nix path-info .#arboricxSite) +// node ext/demo-site/test/browser.mjs [chromium-path] [screenshot-prefix] +import {resolve} from 'node:path'; +if(!process.env.ARBORICX_SITE_ROOT)throw Error('Set ARBORICX_SITE_ROOT to the built arboricxSite, including all runtime assets'); +process.env.TEST_IDE='1'; +process.env.TEST_BACKEND??='js'; +if(process.argv[3])process.env.TEST_SCREENSHOT_PREFIX=process.argv[3]; +const browser=process.argv[2]||'chromium'; +process.argv=[process.argv[0],process.argv[1],resolve(process.env.ARBORICX_SITE_ROOT,'runtime/haskell'),browser]; +await import('./backend-browser.mjs'); diff --git a/ext/demo-site/test/draft-session.test.mjs b/ext/demo-site/test/draft-session.test.mjs @@ -17,7 +17,7 @@ test('browser code has no draft persistence or retired storage module',async()=> test('source remains editable without autosave or recovery controls',async()=>{ const html=await readFile(new URL('../index.html',import.meta.url),'utf8'); assert.doesNotMatch(html,/draft-recovery|draft-save-status|recover-draft|forget-draft|download-draft-source|Draft recovery/); - for(const id of ['source-editor','compile','stop-compile','restore-source']) + for(const id of ['source-editor','compile','stop','restore-source']) assert.match(html,new RegExp(`id="${id}"`)); assert.doesNotMatch(html,/id="edit-source"|id="compiler-disclosure"/); const readme=await readFile(new URL('../README.md',import.meta.url),'utf8'); diff --git a/ext/demo-site/test/expression.test.mjs b/ext/demo-site/test/expression.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {expressionUnit,expressionDiagnostic} from '../dist/expression.js'; +test('expression wrapper preserves layout and avoids supplied/user names',()=>{ + const u=expressionUnit('let\n browserExpression1 = 42\n in browserExpression1',['browserExpression0']); + assert.equal(u.name,'browserExpression2'); + assert.equal(u.source,'browserExpression2 = (\n let\n browserExpression1 = 42\n in browserExpression1\n)'); +}); +test('expression transport rejects empty, malformed Unicode and oversized wrapped source',()=>{ + for(const input of ['',null,'\ud800','x'.repeat(16384)])assert.throws(()=>expressionUnit(input,[])); +}); +test('expression diagnostics map the compiler wrapper back to user coordinates',()=>{ + const e=expressionDiagnostic(Error('Compiler diagnostic 20 at 2:3: unresolved name "nope"')); + assert.equal(e.message,'Compiler diagnostic 20 at expression 1:1: unresolved name "nope"'); + const trap=new WebAssembly.RuntimeError('trap');assert.equal(expressionDiagnostic(trap),trap); +}); diff --git a/ext/demo-site/test/ide-probe.mjs b/ext/demo-site/test/ide-probe.mjs @@ -0,0 +1,74 @@ +// Real single-page workspace. Independent expectations, no substitute evaluator. +const el=id=>document.getElementById(id); +const assert=(ok,message)=>{if(!ok)throw Error(message);}; +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +async function wait(fn){const end=Date.now()+90000;while(Date.now()<end){if(fn())return;await delay(20);}throw Error('Timed out: '+el('status').textContent+' / '+el('result').textContent);} +const status=s=>wait(()=>el('status').textContent===s); +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'));}; +export async function run(control,backend='haskell-wasm'){ + await status('Ready'); + 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'); + assert(document.querySelector('footer a[href="https://eversole.co"]').textContent==='James Eversole','Copyright author link'); + assert(el('editor').compareDocumentPosition(el('ecosystem'))&Node.DOCUMENT_POSITION_FOLLOWING,'Ecosystem follows editor'); + el('settings-toggle').click();assert(!el('settings-panel').hidden&&el('settings-toggle').getAttribute('aria-expanded')==='true','Inspector opens'); + el('settings-close').click();assert(el('settings-panel').hidden&&document.activeElement===el('settings-toggle'),'Close returns focus'); + change('runtime-backend',backend);await status('Ready'); + const requests=async()=>await(await fetch(control+'/requests')).json(); + const before=(await requests()).filter(x=>x.endsWith('/compiler.json')||x.endsWith('/data/compiler.dag')).length; + assert(before===0,'Arrival does not load compiler assets'); + change('output-target','definition');change('export','leaf'); + assert(el('run').textContent==='Show definition'&&el('mode').disabled,'Stored target is visibly distinct from execution'); + for(const [format,want] of [['tricu','t'],['triangle','△'],['dag','tree △\ntree\n'],['ternary','0']]){ + change('output-type',format);el('run').click();await wait(()=>!el('copy-output').disabled); + assert(el('result').textContent===want,'Stored '+format); + assert(/Stored definition leaf.*SHA-256/.test(el('output-provenance').textContent),'Stored provenance'); + } + assert((await requests()).filter(x=>x.endsWith('/compiler.json')||x.endsWith('/data/compiler.dag')).length===before,'Stored output never initializes the compiler'); + change('output-target','result'); + const evaluate=async(source,format)=>{input('expression-editor',source);change('output-type',format);el('run').click();await status('Finished');}; + for(const [format,want] of [['tricu','(t t t)'],['triangle','(△ △ △)'],['ternary','200']]){ + await evaluate('identity (t t t)',format);assert(el('result').textContent===want,'Applied result '+format); + assert(/Evaluated result/.test(el('output-provenance').textContent),'Result not mislabeled as a definition'); + } + await evaluate('identity (t t t)','dag');assert(/tree :n\d+\ntree\n$/.test(el('result').textContent),'Result DAG interface'); + change('mode','io');await evaluate('t "tricuIO" (t 1 (t 0 (t t t)))','tricu');assert(el('result').textContent==='(t t t)','IO result, not IO envelope');change('mode','pure'); + let copied;const clipboard=Object.getOwnPropertyDescriptor(navigator,'clipboard'); + Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async value=>{copied=value;}}}); + const createURL=URL.createObjectURL,click=HTMLAnchorElement.prototype.click;let blob,download; + URL.createObjectURL=value=>{blob=value;return createURL(value);};HTMLAnchorElement.prototype.click=function(){download=this.download;}; + try{ + await evaluate(JSON.stringify('a'.repeat(600)),'tricu');assert(el('result').textContent.length===16384,'Large result has a bounded UI preview'); + assert(/Preview: first 16,384/.test(el('output-provenance').textContent),'Partial preview is explicit'); + el('copy-output').click();await wait(()=>copied!==undefined);assert(copied.length>16384&&copied.startsWith(el('result').textContent),'Copy contains complete source'); + el('download-output').click();assert(download==='result.tri'&&await blob.text()===copied,'Download contains the identical complete source'); + const previousOutput=el('result').textContent; + const expectedHash=el('bundle-hash').value;el('download-bundle').click();await wait(()=>/Downloaded original/.test(el('bundle-download-status').textContent)); + const actual=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await blob.arrayBuffer()))].map(x=>x.toString(16).padStart(2,'0')).join(''); + assert(el('result').textContent===previousOutput,'Bundle download does not replace evaluated output'); + assert(actual===expectedHash&&download===expectedHash+'.arboricx','Bundle download remains original authenticated bytes'); + }finally{URL.createObjectURL=createURL;HTMLAnchorElement.prototype.click=click;if(clipboard)Object.defineProperty(navigator,'clipboard',clipboard);else delete navigator.clipboard;} + // An exponentially expanded value stays small as a physically shared DAG. + const bindings=['x0 = t t t'];for(let i=1;i<=28;i++)bindings.push(`x${i} = t x${i-1} x${i-1}`); + const shared='let\n '+bindings.join('\n ')+'\n in x28'; + await evaluate(shared,'dag');assert(el('result').textContent.length<20000,'Shared result is not flattened'); + input('expression-editor',shared);change('output-type','tricu');el('run').click();await status('Stopped'); + assert(/Expanded tree exceeds 32 MiB/.test(el('result').textContent),'Oversized expansion rejected before building output'); + assert(el('copy-output').disabled&&el('download-output').disabled,'No partial result masquerades as a file'); + await evaluate('identity 52','number');assert(el('result').textContent==='52','Recovery after result-rendering error'); + // Fresh lazy compiler initialization honors the site pin, with no fallback. + change('runtime-backend',backend);await status('Ready'); + await fetch(control+'/fault',{method:'POST',body:JSON.stringify({compilerPin:true})}); + input('expression-editor','identity 53');el('run').click();await status('Stopped'); + assert(/pin mismatch|HTTP|fetch/i.test(el('result').textContent),'Expression compiler pin failure is visible'); + await fetch(control+'/fault',{method:'POST',body:JSON.stringify({compilerPin:false})}); + await evaluate('identity 53','number');assert(el('result').textContent==='53','Retry authenticates the compiler'); + input('bundle-hash','not-a-hash');el('program-hash').requestSubmit();await status('Stopped'); + assert(el('run').disabled&&el('copy-output').disabled,'Invalid replacement never falls back to an earlier package/output'); + 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'); + 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 @@ -0,0 +1,126 @@ +// Actual page, all three 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)); +async function wait(fn){const end=Date.now()+90000;while(Date.now()<end){if(fn())return;await delay(20);}throw Error('Timed out: '+el('status').textContent+' / '+el('result').textContent+' / '+el('compile-diagnostic').textContent);} +const status=text=>wait(()=>el('status').textContent===text); +const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; +const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; +export async function run(control,backend='haskell-wasm',alreadyUsed=false){ + await status('Ready'); + change('runtime-backend',backend);await status('Ready'); + assert(!el('use-guided-inputs').checked,'Expression input is the default'); + const evaluate=async(expression,value,codec='number')=>{ + input('expression-editor',expression);change('output-type',codec);el('run').click();await status('Finished'); + assert(el('result').textContent===(typeof value==='string'?value:JSON.stringify(value,null,2)),el('result').textContent); + }; + const requests=async()=>await(await fetch(control+'/requests')).json(); + if(!alreadyUsed)assert(!(await requests()).some(x=>x.endsWith('/data/compiler.dag')),'Loading alone does not fetch the compiler kit'); + const fixtures=await(await fetch('/zig-fixtures.json')).json(); + const {Graph}=await import('/nested/site/dist/graph.js'); + const {view,readList}=await import('/nested/site/dist/codecs.js'); + const {printLiteral}=await import('/nested/site/dist/literal-input.js'); + for(const [name,demo] of Object.entries(fixtures.demos)){ + change('program-picker','demo:'+name);await status('Ready'); + const c=demo.haskellCases[0];change('export',c.export); + const asSource=ternary=>{let at=0;const read=()=>{const tag=ternary[at++];return tag==='0'?'t':tag==='1'?`(t ${read()})`:`(t ${read()} ${read()})`;};return read();}; + const graph=new Graph(),templates=JSON.parse(el('arguments').value); + const typedSource=(tree,template)=>{ + if(!template||template.type==='tree')return asSource(graph.toTernary(tree,100000)); + if(template.type==='list')return '['+readList(graph,tree).map((child,i)=>typedSource(child,template.value[i])).join(' ')+']'; + return printLiteral({type:template.type,value:view(graph,tree,template.type).value}); + }; + input('expression-editor',[c.export,...c.arguments.map((arg,i)=>typedSource(graph.fromTernary(arg),templates[i]))].join(' ')); + const expected=view(graph,graph.fromTernary(c.result),el('output-type').value); + assert(!['tree','list'].includes(expected.type),'Full scalar/record golden required'); + el('run').click(); + if(demo.mode==='io'){await status('Waiting for input');input('line-input',c.input);el('input-form').requestSubmit();} + await status('Finished'); + assert(el('result').textContent===(typeof expected.value==='string'?expected.value:JSON.stringify(expected.value,null,2)),'Complete expression golden: '+name+' '+el('result').textContent); + assert(el('terminal').textContent===c.terminal,'Complete terminal golden: '+name); + } + change('program-picker','demo:intro');await status('Ready'); + await evaluate('identity (first 42 7)','42'); + await evaluate('let\n local x = identity x\n in local 19','19'); + const exports=[...el('export').options].map(x=>x.value).join(','); + input('expression-editor','unknownBrowserName');el('run').click();await status('Stopped'); + assert(/at expression 1:1.*unresolved name/.test(el('result').textContent),'Expression reports user coordinates'); + input('expression-editor','7)\ninjected = 9\nescape = (');el('run').click();await status('Stopped'); + assert(/Compiler diagnostic|one expression/.test(el('result').textContent),'Expression cannot inject definitions'); + await evaluate('identity 23','23'); + assert([...el('export').options].map(x=>x.value).join(',')===exports,'Expressions never publish temporary names'); + // No editable source required: bind exact published library exports to trees. + change('program-picker','package:tricu-base');el('load-release').click();await status('Ready'); + assert(el('source-editor').hidden,'Library has no editable source'); + assert([...el('export').options].some(x=>x.value==='append'),'Published list export exists'); + await evaluate('append "hello " "λ🙂"','hello λ🙂','string'); + await evaluate('showNumber 30','30','string'); + // Source revision safety, deterministic late completion, and edit coalescing. + change('program-picker','demo:intro');await status('Ready'); + await evaluate('answer','42'); + const NativeWorker=globalThis.Worker,held=[],submitted=[];let hold=true; + globalThis.Worker=class extends NativeWorker { + postMessage(message,...args){if(message.type==='compile')submitted.push(message.source);super.postMessage(message,...args);} + set onmessage(handler){super.onmessage=event=>{if(hold&&['compiled','error'].includes(event.data.type))held.push(()=>handler(event));else handler(event);};} + }; + const original=el('source-editor').value; + input('source-editor',original.replace('first 42 7','first 43 7')); + assert(el('run').disabled,'No running stale source'); + assert(!el('result-stale').hidden&&el('result').textContent==='42','Keep previous result, marked stale'); + await status('Compiling');assert(!el('source-editor').disabled,'Editing continues while compiling'); + input('source-editor',original.replace('first 42 7','first 44 7')); + input('source-editor',original.replace('first 42 7','first 45 7')); + await wait(()=>held.length>0);assert(submitted.length===1,'Do not restart on each keystroke'); + hold=false;held.shift()();await status('Compiled'); + assert(submitted.length===2&&submitted[1].includes('first 45 7'),'Compile only latest queued revision'); + assert(el('result').textContent==='42','Compiling never executes'); + await evaluate('answer','45'); + hold=true;input('source-editor','answer = missingStaleName'); + await wait(()=>held.length>0);input('source-editor','answer = 45'); + hold=false;held.shift()();await status('Compiled'); + assert(el('compile-diagnostic').hidden,'Stale diagnostics are discarded too'); + // A diagnostic cannot leave a preceding definition runnable. + input('source-editor','answer = missingLatestName');await status('Compilation failed'); + assert(el('run').disabled&&!el('compile-diagnostic').hidden,'Invalid latest source blocks evaluation'); + assert(el('result').textContent==='45'&&!el('result-stale').hidden,'Diagnostic preserves prior result'); + input('source-editor','answer = 46');await status('Compiled');await evaluate('answer','46'); + // Runtime switching rebuilds automatically but cannot carry definitions across. + const other=backend==='haskell-wasm'?'zig-wasm':'haskell-wasm'; + change('runtime-backend',other);assert(el('run').disabled,'Backend change invalidates runnable revision'); + await status('Compiled');await evaluate('answer','46'); + change('runtime-backend',backend);await status('Compiled'); + // Stop cancels queued idle work; it is not a request to restart compilation. + input('source-editor','answer = 47');el('stop').click();await delay(900); + assert(el('status').textContent==='Stopped','Stop suppresses idle work'); + input('source-editor','answer = 48');await status('Compiled');await evaluate('answer','48'); + el('source-editor').dispatchEvent(new CompositionEvent('compositionstart')); + input('source-editor','answer = 49');await delay(900); + assert(el('status').textContent==='Changes pending','IME input is not compiled mid-composition'); + el('source-editor').dispatchEvent(new CompositionEvent('compositionend'));await status('Compiled');await evaluate('answer','49'); + input('source-editor','answer = 50');change('program-picker','demo:intro');await status('Ready');await delay(900); + assert(el('status').textContent==='Ready','Changing package cancels queued source compilation'); + // An IO expression is never executed just by editing/compiling it. + document.querySelector('[data-compiler-example=input]').click();await status('Compiled'); + assert(el('input-form').hidden&&el('terminal').textContent==='','Idle compilation has no host effects'); + document.querySelector('[data-compiler-example=input]').click(); + assert(el('run').disabled,'Even identical source in a new scratch context must wait for its own revision'); + await status('Compiled'); + input('expression-editor','io');el('run').click();await status('Waiting for input'); + assert(el('source-editor').disabled,'Background edits cannot interrupt an IO session'); + assert(el('reset').disabled,'Clear output cannot interrupt an active IO session'); + input('line-input','hello λ🙂');el('input-form').requestSubmit();await status('Finished'); + assert(el('result').textContent==='hello λ🙂','Expression IO result'); + // Invalid guided arguments must not leave a stale downloadable payload. + el('use-guided-inputs').checked=true;el('use-guided-inputs').dispatchEvent(new Event('change')); + input('arguments','invalid JSON');el('run').click();await status('Stopped'); + assert(el('copy-output').disabled&&el('download-output').disabled,'Displayed errors cannot download old output'); + input('arguments','[]');el('use-guided-inputs').checked=false;el('use-guided-inputs').dispatchEvent(new Event('change')); + // Unused divergent trees must not be normalized merely to build an environment. + input('source-editor','id x = x\nbomb = (x : x x) (x : x x)');await status('Compiled'); + change('mode','pure');await evaluate('id 51','51'); + input('expression-editor','bomb');el('run').click();await status('Running');await delay(150); + assert(el('status').textContent==='Running','CPU-bound expression is still running'); + el('stop').click();await status('Stopped'); + globalThis.Worker=NativeWorker; + return {userAgent:navigator.userAgent,checks:[backend+' expressions: published, read-only library, local bindings, diagnostics, IO','idle compilation: stale completion rejection, coalescing, no implicit execution, output retention, Stop, backend rebuild']}; +} diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -1,183 +1,52 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {readFile,access} from 'node:fs/promises'; - const here=new URL('../',import.meta.url); -test('site copy uses plain quotes and focuses on the Tree Calculus ecosystem',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - assert.doesNotMatch(html,/[\u2014\u2018\u2019\u201c\u201d]/); - for(const file of ['src/app.ts','src/export-app.ts','style.css']) - assert.doesNotMatch(await readFile(new URL(file,here),'utf8'),/[\u2014\u2018\u2019\u201c\u201d]/); - assert.match(html,/Arboricx and tricu are research experiments\./); - assert.match(html,/Use \[\] for/); - assert.equal((html.match(/<article>/g)||[]).length,6); - for(const topic of ['structural inspection','Familiar syntax','interaction tree','Arboricx packages','web server','compiles itself']) - assert.ok(html.includes(topic),topic); - assert.doesNotMatch(html,/\/src\/branch\/main\//); - const sourceLinks=[...html.matchAll(/href="https:\/\/git\.eversole\.co\/James\/tricu\/file\/([^"#]+)\.html(?:#[^"]*)?"/g)]; - assert.ok(sourceLinks.length>=6,'stagit documentation and source links'); - for(const match of sourceLinks) - await access(new URL('../../../'+match[1],import.meta.url)); -}); - -test('demo-site build and reproduction paths follow the directory move',async()=>{ - for(const file of ['README.md','build.sh','prepare.py','test/browser.mjs']){ - const text=await readFile(new URL(file,here),'utf8'); - // ext/wasm now also contains the supported Zig adapter. Reject stale site - // build/fixture paths, not legitimate runtime implementation references. - assert.doesNotMatch(text,/ext\/wasm\/(?:build\.sh|prepare\.py|index\.html|test\/browser\.mjs)/); - assert.match(text,/ext\/demo-site/); - } -}); - -test('shared pane has accessible tabs without execution-limit controls',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - assert.equal((html.match(/role="tab"/g)||[]).length,3); - assert.equal((html.match(/role="tabpanel"/g)||[]).length,3); - assert.match(html,/id="tab-how"[^>]*aria-selected="true"/); - assert.doesNotMatch(html,/limits-defaults|id="uncapped"|id="compiler-uncapped"|resource-mode|execution-limits/); - 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); -}); - -test('header keeps Source; footer attribution matches README authors and resources',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - const nav=html.match(/<nav aria-label="Main">(.*?)<\/nav>/s)[1]; - assert.equal((nav.match(/<a /g)||[]).length,1);assert.match(nav,/>Source<\/a>/); - assert.doesNotMatch(html,/lambda-forward|A compiler is a program|Explore a tree, start a conversation/i); - assert.match(html,/The compiler is just a tree too/); - const footer=html.match(/<footer.*?<\/footer>/s)[0]; - assert.equal((footer.match(/class="attribution"/g)||[]).length,1); - const readme=await readFile(new URL('../../../README.md',import.meta.url),'utf8'); - const links=new Map([...readme.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)].map(m=>[m[1],m[2]])); - for(const name of ['Tree Calculus','Triage Calculus','Barry Jay','Johannes Bader','treecalcul.us']){ - assert.ok(footer.includes(`href="${links.get(name)}">${name}</a>`),name); - } -}); - -test('compiler copy trims helper disclosure and entry-point instruction',async()=>{ - const html=await readFile(new URL('../index.html',import.meta.url),'utf8'); - assert.doesNotMatch(html,/<summary>Included contract helpers<\/summary>|Select main to run your entry point/); - assert.match(html,/Use \[\] for its arguments/); - assert.match(html,/id="stop-compile"/); -}); - - -test('definition export is a peer panel with its own controls and shared package slot',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]); - assert.equal(new Set(ids).size,ids.length,'unique DOM IDs'); - for(const id of ['how','playground','export-definition']){ - assert.match(html,new RegExp(`id="tab-${id}"[^>]*aria-controls="${id}"`)); - assert.match(html,new RegExp(`id="${id}"[^>]*aria-labelledby="tab-${id}"`)); - } - const playground=html.slice(html.indexOf('<section id="playground"'),html.indexOf('<section id="export-definition"')); - const panel=html.slice(html.indexOf('<section id="export-definition"'),html.indexOf('<section id="how"')); - for(const id of ['tree-definition','tree-format','export-tree','stop-tree','tree-preview','copy-tree','download-tree','tree-provenance']){ - assert.ok(panel.includes(`id="${id}"`),id); - assert.ok(!playground.includes(`id="${id}"`),id+' not nested in Playground'); - } - assert.match(playground,/data-open-pane="export-definition"/); - assert.match(panel,/data-package-slot/); - assert.match(panel,/Nothing is run and no arguments are applied/); - assert.match(panel,/32 MiB.*128 MiB/); - assert.match(panel,/two minutes/); -}); - - -test('hero pronunciation, ecosystem order, and desktop shape alignment',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - const css=await readFile(new URL('style.css',here),'utf8'); - assert.match(html,/Arboricx \/ <span class="pronunciation" aria-label="arbor-icks">ˈɑːrbərɪks<\/span>/); - assert.deepEqual([...html.matchAll(/<article><h3>(.*?)<\/h3>/g)].map(m=>m[1]),[ - 'Portable Arboricx packages.', 'A web server made of trees.', - 'A compiler that compiles itself.', 'Programs that inspect programs.', - 'IO as an interaction tree.', 'Familiar syntax.', - ]); - assert.match(css,/\.pronunciation\s*\{[^}]*text-transform:none/); - assert.match(css,/@media \(min-width:950px\)\s*\{\s*\.shapes\s*\{[^}]*top:70px/); -}); - -test('unified authoring is directly editable without a slow-compilation warning',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - assert.doesNotMatch(html,/id="tab-compiler"/); - for(const id of ['source-editor','authorship','restore-source','download-bundle','program-picker','package-version'])assert.match(html,new RegExp(`id="${id}"`)); - assert.doesNotMatch(html,/<dialog|compile-consent|can take minutes|Standard|tab crash/); - assert.doesNotMatch(html,/compiler-disclosure|Compilation uses a JavaScript Tree Calculus reducer|can take time or extra memory/); - assert.doesNotMatch(html,/limits under Advanced/); -}); - -test('primary workspace is compact and Advanced has no resource editors',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - assert.match(html,/<select id="program-picker"/); - assert.doesNotMatch(html,/role="group" aria-label="Curated packages"/); - assert.match(html,/<section class="bundle-details" aria-label="Bundle details">/); - assert.doesNotMatch(html,/program-selection-status|Choose a program to load/); - assert.doesNotMatch(html,/id="demo-picker"|id="package-name"|Published packages and provenance/); - for(const id of ['program-release','program-hash'])assert.match(html,new RegExp(`id="${id}"`)); - assert.match(html,/Bundle hash \(SHA-256\)/); - assert.match(html,/>Load bundle<\/button>/); - const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<details class="trace-panel"')); - assert.doesNotMatch(advanced,/uncapped|resource-mode|limits-editor/); - assert.match(advanced,/id="mode"/); - for(const id of ['argument-fields','add-argument','raw-arguments'])assert.match(html,new RegExp(`id="${id}"`)); - assert.match(html,/One literal per field/); - assert.match(html,/<details class="trace-panel">/); -}); - -test('source-first workspace has full-width source and explicit compilation',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - const app=await readFile(new URL('src/app.ts',here),'utf8'); - const css=await readFile(new URL('style.css',here),'utf8'); - assert.ok(html.indexOf('id="source-section"')<html.indexOf('id="try-program"')); - assert.ok(html.indexOf('id="compile"')<html.indexOf('id="try-program"')); - assert.match(html,/<details id="source-section"[^>]* open>/); - assert.match(html,/<details id="try-program"[^>]* open>/); - assert.ok(html.indexOf('class="trace-panel"')>html.indexOf('id="try-program"')); - assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('id="export-definition"')); - assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('>Export this definition →</a>')); - assert.match(html,/<details id="source-section"/); - assert.doesNotMatch(html,/id="source-editor"[^>]*readonly/); - assert.doesNotMatch(html,/id="edit-source"/); - assert.match(app,/sourceEditor.classList.toggle\('modified', sourceModified\)/); - assert.match(html,/>Compile draft<\/button>/); - assert.doesNotMatch(app,/Compile & run|compileDraft\(true\)/); - assert.match(app,/draft \? !compiled : !loaded/); - assert.match(html,/id="draft-run-note"/); - assert.match(html,/<details id="result-inspection"/); - assert.match(html,/class="hero wrap"/); - assert.match(html,/class="workspace wrap"/); - assert.match(css,/\.wrap\s*\{[^}]*max-width:1120px;[^}]*padding-left:28px;[^}]*padding-right:28px;/); - assert.doesNotMatch(css,/\.workspace\s*\{[^}]*(?:max-width|width:|padding-inline|padding-left|padding-right)/); -}); - -test('bundle download stays beside primary Load controls, never inside Advanced',async()=>{ - const html=await readFile(new URL('index.html',here),'utf8'); - const toolbar=html.slice(html.indexOf('id="package-toolbar"'),html.indexOf('<section class="bundle-details"')); - for(const id of ['program-release','package-version','load-release','program-hash','load','download-bundle'])assert.match(toolbar,new RegExp(`id="${id}"`)); - assert.equal((html.match(/id="download-bundle"/g)||[]).length,1); - assert.match(toolbar,/id="load-release" type="button" class="primary"/); - assert.match(toolbar,/id="load" type="submit" class="primary"/); - assert.doesNotMatch(toolbar,/id="download-bundle"[^>]*class="primary"|bundle-download-help/); - assert.match(toolbar,/Download \.arboricx/); - const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<details class="trace-panel"')); - assert.doesNotMatch(advanced,/download-bundle/); - const app=await readFile(new URL('src/app.ts',here),'utf8'); - assert.match(app,/el\('program-release'\)\.hidden = name === undefined;/); +const read=file=>readFile(new URL(file,here),'utf8'); +test('single flowing editor starts before ecosystem, without tabs or graph JSON UI',async()=>{ + const html=await read('index.html'); + assert.doesNotMatch(html,/role="tab|data-open-pane|id="graph-result"|id="export-definition"|class="run-tools"/); + assert.ok(html.indexOf('class="hero wrap"')<html.indexOf('id="editor"')); + assert.ok(html.indexOf('id="editor"')<html.indexOf('id="ecosystem"')); + const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]);assert.equal(ids.length,new Set(ids).size); + 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(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))); +}); +test('editor preserves authoring, IO, package and immutable download controls',async()=>{ + const html=await read('index.html'); + for(const id of ['source-editor','expression-editor','compile','stop','restore-source','output-type','mode','step-mode','input-form','line-input','next','arguments','raw-arguments','use-guided-inputs','download-bundle','package-version','program-hash','share'])assert.ok(html.includes(`id="${id}"`),id); + assert.match(html,/Edits compile after a pause/);assert.match(html,/Stored definitions are read without evaluation/); + assert.doesNotMatch(html,/id="edit-source"|compile-consent|slow-compilation|uncapped|execution-limits/); + const app=await read('src/app.ts');assert.match(app,/sourceEditor.classList.toggle\('modified', sourceModified\)/); + assert.match(app,/sourceEditor.value !== compiledSource/); + assert.match(app,/data.hash !== selection\?\.hash/); assert.match(app,/r\.name === name && r\.version === versionPicker\.value/); - const css=await readFile(new URL('style.css',here),'utf8'); - assert.match(css,/\.release-choice select \{ width:9rem;/); - assert.match(css,/@media\(max-width:740px\)[\s\S]*#download-bundle \{ width:100%;/); }); - -test('production workers cannot inherit finite Graph defaults or request budgets',async()=>{ - for(const file of ['src/worker.ts','src/compiler-worker.ts']) { - const source=await readFile(new URL(file,here),'utf8'); - assert.match(source,/const budget = BROWSER_BUDGET/); - assert.doesNotMatch(source,/executionSettings|executionBudget|\['limits'\]|\['uncapped'\]/); +test('inspector supports mobile modality, escape, focus return and focus containment',async()=>{ + const html=await read('index.html'),controller=await read('src/inspector.ts'); + assert.match(html,/aria-controls="settings-panel" aria-expanded="false"/); + for(const token of ['aria-modal','aria-expanded','element.inert','Escape','Tab','toggle.focus()','getClientRects'])assert.ok(controller.includes(token),token); + const css=await read('style.css');assert.match(css,/\.workspace\{width:100%;max-width:none;margin:0;border-left:0;border-right:0;border-radius:0/); + assert.match(css,/#source-editor\{font-size:16px;padding:16px;width:100%/); + assert.match(css,/prefers-reduced-motion/); +}); +test('ecosystem remains with a copyright-only footer',async()=>{ + const html=await read('index.html');assert.equal((html.match(/<article>/g)||[]).length,6); + assert.match(html,/Copyright 2026 <a href="https:\/\/eversole.co">James Eversole<\/a>/); + assert.doesNotMatch(html,/>Demo documentation<\/a>/); + 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()=>{ + 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']){ + 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 readFile(new URL(file,here),'utf8'),/ExecutionLimits|readSettings|limits-change|uncapped|activeMilliseconds/); + 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))); }); diff --git a/ext/demo-site/test/tree-result.test.mjs b/ext/demo-site/test/tree-result.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Graph} from '../dist/graph.js'; +import {snapshotBundle,treeResult,isTreeResult} from '../dist/tree-result.js'; +import {view} from '../dist/codecs.js'; +test('result snapshots preserve physical sharing and serialize real values',()=>{ + const g=new Graph(),x=g.fork(g.leaf,g.leaf),root=g.fork(x,x); + const snapshot=g.snapshot(root),b=snapshotBundle(snapshot); + assert.equal(b.nodes.length,3);assert.equal(b.nodes[2].left,b.nodes[2].right); + assert.equal(treeResult(snapshot,'tricu').value,'(t (t t t) (t t t))'); + assert.equal(treeResult(snapshot,'triangle').value,'(△ (△ △ △) (△ △ △))'); + assert.equal(view(g,root,'ternary').value,'2200200'); + assert.match(treeResult(snapshot,'dag').value,/tree :n2\ntree\n$/); + assert.equal(g.snapshot(x,2).truncated,false,'Known duplicate frontier does not count as new nodes'); + assert.equal(isTreeResult('tree'),false); +}); +test('result snapshots reject partial graphs, missing nodes, aliases and cycles honestly',()=>{ + const g=new Graph(),root=g.stem(g.stem(g.leaf)); + assert.throws(()=>treeResult(g.snapshot(root,1),'dag'),/partial export/); + assert.throws(()=>snapshotBundle({root:0,nodes:[],aliases:[],truncated:false}),/Incomplete/); + assert.throws(()=>snapshotBundle({root:0,nodes:[],aliases:[{from:0,to:1},{from:1,to:0}],truncated:false}),/alias cycle/); + assert.throws(()=>snapshotBundle({root:0,nodes:[{id:0,kind:'stem',children:[0]}],aliases:[],truncated:false}),/Cyclic/); + assert.throws(()=>g.snapshot(root,100001),/snapshot budget/); +}); +test('complete transport supports more than preview nodes, bounds expansion, and resolves aliases',()=>{ + const g=new Graph();let root=g.leaf;for(let i=0;i<600;i++)root=g.stem(root); + assert.equal(g.preview(root,256).truncated,true);assert.equal(snapshotBundle(g.snapshot(root)).nodes.length,601); + let shared=g.leaf;for(let i=0;i<28;i++)shared=g.fork(shared,shared); + const snapshot=g.snapshot(shared);assert.ok(treeResult(snapshot,'dag').value.length<3000); + assert.throws(()=>treeResult(snapshot,'tricu'),/32 MiB/); + const aliases={root:8,aliases:[{from:8,to:0}],nodes:[{id:0,kind:'leaf',children:[]}],truncated:false}; + assert.equal(treeResult(aliases,'tricu').value,'t'); +}); diff --git a/ext/haskell-wasm/BrowserRuntime.hs b/ext/haskell-wasm/BrowserRuntime.hs @@ -138,6 +138,10 @@ preview limit root=do view :: String -> T -> IO J view kind tree=do value<-case kind of + "tricu"->preview 100000 tree + "triangle"->preview 100000 tree + "dag"->preview 100000 tree + "ternary"->preview 100000 tree "tree"->preview 64 tree "string"->S <$> text 4096 tree "number"->S . show <$> natural tree diff --git a/ext/haskell-wasm/README.md b/ext/haskell-wasm/README.md @@ -225,6 +225,8 @@ protocol: | --- | --- | | `init` | Default loads the build-pinned compiler kit; `compiler:false` starts a package-only session without downloading the kit. Ready identifies `compilerHash` (or null). | | `load` | `bytes: ArrayBuffer`, lowercase whole-bundle `hash`; returns export names, atomically replaces definitions. | +| `compiler-init` | Lazily authenticate/load the prepared compiler kit into an existing package session; returns `compilerHash` without replacing definitions. | +| `expression` | Private adapter request: one wrapped expression (`name`, `source`), `mode`, `output`. Compiles against current definitions without changing them; returns a value view or starts the ordinary IO event protocol. Requires an initialized compiler and idle IO. | | `compile` | `source`, optional `environment`; returns newline-separated names, preserves old definitions on failure. | | `evaluate` | `name`, typed `arguments`, `output`; returns the site's `ValueView`. | | `preview` / `snapshot` | `name`, typed `arguments`, optional `limit`; returns `{root,nodes,aliases,truncated}`. | @@ -274,3 +276,9 @@ The upstream README at this revision recommends Cabal-install 3.14 or earlier because of a reported 3.16 regression. The selected flavour targets WASI Preview 1; browser validation does not imply native WASI Preview 2 or component-model support. + +The editor's `tricu`, `triangle`, `dag` and `ternary` result interpretations use +`view` to return a bounded complete snapshot (up to 100,000 nodes) through the +private protocol. The site adapter then resolves snapshot aliases and formats the +actual pure/IO result with the shared tree serializer. It refuses incomplete or +cyclic snapshots. This does not change stored-bundle export or compiler checkpoints. diff --git a/ext/haskell-wasm/Reactor.hs b/ext/haskell-wasm/Reactor.hs @@ -194,6 +194,32 @@ request ptr len = guardRequest $ do (trees,n)<-R.loadDAG dag defs<-bindings trees n entries compileWith compiler (ofList [Fork (ofString name) tree | (name,tree)<-defs]) source + -- Compile against existing named trees without serializing, evaluating or + -- replacing them. The temporary result lives only in this call/IO session. + ("expression",[nameBytes,sourceBytes,modeBytes,codec])->do + requireIdle + ctx<-readIORef context + (compiler,_)<-maybe (fail "compiler not initialized") pure ctx + name<-R.utf8 nameBytes + source<-R.utf8 sourceBytes + mode<-R.utf8 modeBytes + kind<-R.utf8 codec + unless(not(null source)&&length source<=16384)(fail "source scalar limit exceeded") + defs<-readIORef current + let env=ofList [Fork (ofString key) tree | (key,tree)<-defs] + values<-either fail pure (C.definitions(C.compile compiler env (ofString source))) + term<-case values of + [(key,value)] | key==name->pure value + _->fail "Enter one expression, not a source unit" + case mode of + "pure"->do + value<-R.view kind term + reply("ok\n"++R.json value) + "io"->do + phase<-R.beginIO term + emitEvent(R.Pending phase) + writeIORef ioResult Nothing + _->fail "invalid expression mode" ("load",dag:entries)->do requireIdle (trees,n)<-R.loadDAG dag diff --git a/ext/haskell-wasm/browser-worker.mjs b/ext/haskell-wasm/browser-worker.mjs @@ -1,7 +1,8 @@ import {WASI,File,OpenFile,ConsoleStdout,PreopenDirectory} from './shim/index.js'; import pins,{compilerHash} from './pins.mjs'; import {frame,authenticatedBundle,loadFields,application,evaluation,snapshotFields,snapshotBundle,exportedTree,environmentFields,emptyEnvironment} from './bridge.mjs'; -let instance,booted=false,dead=false,stderr="",storedBundle=null,busy=false; +let instance,booted=false,dead=false,stderr="",storedBundle=null,busy=false,compilerReady=false; +const dataFiles=new Map(); const encoder=new TextEncoder(),decoder=new TextDecoder('utf-8',{fatal:true}); function scalarText(s,limit){ if(typeof s!=='string')throw Error('Expected string'); @@ -59,20 +60,31 @@ onmessage=async({data:m})=>{ if(m.type==='init'){ if(booted)throw Error('Already initialized');booted=true; const files=m.compiler===false?[]:await Promise.all(Object.keys(pins).map(async name=>[name,await checkedFile(name,m.corrupt===name)])); + for(const [name,file] of files)dataFiles.set(name,file); const fetched=performance.now(); const response=await fetch(new URL('reactor.wasm',import.meta.url)); if(!response.ok)throw Error('WASM fetch failed'); const module=await WebAssembly.compileStreaming(response); const compiled=performance.now(); - const wasi=new WASI([],[],[new OpenFile(new File([])),ConsoleStdout.lineBuffered(console.log),ConsoleStdout.lineBuffered(s=>{stderr=(stderr+s+"\n").slice(-4096);}),new PreopenDirectory('/data',files)]); + const wasi=new WASI([],[],[new OpenFile(new File([])),ConsoleStdout.lineBuffered(console.log),ConsoleStdout.lineBuffered(s=>{stderr=(stderr+s+"\n").slice(-4096);}),new PreopenDirectory('/data',dataFiles)]); instance=await WebAssembly.instantiate(module,{wasi_snapshot_preview1:wasi.wasiImport}); wasi.initialize(instance);instance.exports.tricu_rts_init(); const initialized=performance.now(); bytes=call(m.compiler===false?'tricu_initialize_empty':'tricu_initialize'); + compilerReady=m.compiler!==false; phases={data_ms:fetched-begin,wasm_fetch_compile_ms:compiled-fetched,instantiate_rts_ms:initialized-compiled,load_ms:performance.now()-initialized}; }else{ if(!instance)throw Error('Not initialized'); - if(m.type==='compile'){ + if(m.type==='compiler-init'){ + if(!compilerReady){ + const files=await safe(()=>Promise.all(Object.keys(pins).map(async name=>[name,await checkedFile(name)]))); + for(const [name,file] of files)dataFiles.set(name,file); + bytes=call('tricu_initialize');compilerReady=true; + }else bytes=encoder.encode('ready').buffer; + }else if(m.type==='expression'){ + await safe(()=>{scalarText(m.source,16384);if(typeof m.name!=='string'||!/^browserExpression[0-9]+$/.test(m.name)||!['pure','io'].includes(m.mode))throw Error('Invalid expression request');}); + bytes=operation(['expression',m.name,m.source,m.mode,m.output]); + }else if(m.type==='compile'){ let source; try{source=scalarText(m.source,16384);if(!source.trim())throw Error('Enter a standalone source unit');}catch(e){e.recoverable=true;throw e;} if(m.environment===undefined)bytes=call('tricu_compile',source); @@ -124,7 +136,7 @@ onmessage=async({data:m})=>{ }else if(['dump','reset','collect'].includes(m.type)){bytes=call('tricu_'+m.type);if(m.type==='reset')storedBundle=null;} else{const e=Error('Unknown operation');e.recoverable=true;throw e;} } - postMessage({id:m.id,ok:true,bytes,milliseconds:performance.now()-begin,linearBytes:instance.exports.memory.buffer.byteLength,phases,compilerHash:m.type==='init'?(m.compiler===false?null:compilerHash):undefined},[bytes]); + postMessage({id:m.id,ok:true,bytes,milliseconds:performance.now()-begin,linearBytes:instance.exports.memory.buffer.byteLength,phases,compilerHash:['init','compiler-init'].includes(m.type)?(compilerReady?compilerHash:null):undefined},[bytes]); }catch(error){ const fatal=m.type==='init'||!error.recoverable; if(fatal)dead=true; diff --git a/ext/wasm/README.md b/ext/wasm/README.md @@ -261,3 +261,19 @@ or host/driver failure. Persistent definitions are owned outside run scopes. Resource/trap failures poison the session and require replacement, not reuse. The site reports actual linear memory only; raw diagnostic counts are used in ownership/collection tests, not presented as JS work or total-process memory. + +The production adapter also supports `runExpression(source, output, mode, host)`. +It lazily authenticates/loads the pinned compiler when needed and binds the +existing definition handles into a temporary compilation environment. The +single expression result is scoped through observation/IO; its temporary name +is not added to definitions. No stored tree is flattened to reconstruct source. +The same exclusive async scope releases transient handles on success or error. + +`WasmRuntime.snapshot` uses the WASM-only `arb_host_snapshot_unbounded` transport +entrypoint for result files: 1–100,000 observed nodes, with unbounded reduction +work per observation. The existing preview entrypoints retain their 256-node +ceiling. Snapshot labels preserve sharing, use bounded dynamic storage, and never +become session handles. `site-session.mjs` formats complete snapshots with the +shared serializer; incomplete snapshots and oversized expanded files fail rather +than producing partial downloads. Stored definition export still reads immutable +bundle records without evaluation. diff --git a/ext/wasm/runtime.mjs b/ext/wasm/runtime.mjs @@ -120,6 +120,13 @@ export class WasmRuntime { fuel ??= 1_000_000n; if (typeof fuel !== 'bigint' || fuel < 0n || fuel > 0xffffffffffffffffn) throw new Error('Invalid preview fuel'); const bytes = unlimited ? this.output('arb_host_preview_unbounded', x, limit) : this.output('arb_host_preview', x, limit, fuel); + return this.decodeSnapshot(bytes); + } + snapshot(x,limit=100000) { + if(!Number.isInteger(limit)||limit<1||limit>100000)throw Error('Invalid snapshot budget'); + return this.decodeSnapshot(this.output('arb_host_snapshot_unbounded',x,limit)); + } + decodeSnapshot(bytes) { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (view.getUint32(0,true) !== 1) throw new Error('Unsupported preview format'); const nodes = [], count = view.getUint32(8,true); diff --git a/ext/wasm/site-session.mjs b/ext/wasm/site-session.mjs @@ -3,7 +3,7 @@ import {WasmRuntime} from './runtime.mjs'; export async function create({base,compiler}) { const load=name=>import(new URL(`dist/${name}.js`,base).href); - const [bundle,codec,comp,contracts,packages]=await Promise.all(['bundle','codecs','compiler','contracts','packages'].map(load)); + const [bundle,codec,comp,contracts,packages,expressions,treeResults]=await Promise.all(['bundle','codecs','compiler','contracts','packages','expression','tree-result'].map(load)); const bytes=await bundle.fetchBytes(new URL('runtime/zig/arboricx.wasm',base)); const g=await WasmRuntime.create(bytes,{unbounded:true}); // The shared DAG reader's tick is JS accounting, not a native work ceiling. @@ -29,6 +29,7 @@ export async function create({base,compiler}) { return codec.makeLiteral(g,value); } function view(h,type) { + if(treeResults.isTreeResult(type))return treeResults.treeResult({...g.snapshot(h),aliases:[]},type); if(type==='string')return {type,value:g.toText(h)}; if(type==='tree')return {type,value:{...g.preview(h),aliases:[]}}; if(type==='list')return {type,value:codec.readList(g,h,32).map(x=>({...g.preview(x,8),aliases:[]}))}; @@ -69,6 +70,13 @@ export async function create({base,compiler}) { return reader(mode==='io'?await io(applied,host):applied); });}finally{if(!g.poisoned)g.collect();} }); + async function ensureCompiler(){ + if(compilerHash)return; + const hash=comp.compilerDescriptor(await fetchJSON('compiler.json',4096)).hash; + const next=importBundle(await bundle.fetchBundle(new URL(`/_arboricx/bundle/hash/${hash}`,base),hash,bundle.COMPILER_BUNDLE_LIMIT)); + if(!next.has(comp.COMPILER_EXPORT)){release(next);throw Error('Missing stable compiler entrypoint');} + library=next;compilerHash=hash; + } const session={ get disposed(){return closed;}, load(bytes,hash){return exclusive(async()=>{ @@ -108,6 +116,19 @@ export async function create({base,compiler}) { output=codec.outputType(output); return execute(name,args,mode,host,h=>view(h,output)); }, + runExpression(source,output,mode,host){return exclusive(async()=>{ + const unit=expressions.expressionUnit(source,definitions.keys()); + output=codec.outputType(output); + if(!['pure','io'].includes(mode))throw Error('Unsupported execution mode'); + await ensureCompiler(); + try{return await g.scopeAsync(async()=>{ + const environment=codec.list(g,[...definitions].map(([name,h])=>g.fork(g.ofText(name),h))); + const values=comp.compiledDefinitions(g,g.apply(library.get(comp.COMPILER_EXPORT),[environment,g.ofText(unit.source,16384)])); + if(values.size!==1||!values.has(unit.name))throw Error('Enter one expression, not a source unit'); + const result=values.get(unit.name); + return view(mode==='io'?await io(result,host):result,output); + });}catch(error){throw expressions.expressionDiagnostic(error);}finally{if(!g.poisoned)g.collect();} + });}, // Explicit, bounded full observation for independent embedding conformance; // never used to preserve definitions or to implement the site's Run button. inspect(name,args,mode,host){return execute(name,args,mode,host,h=>g.toTernary(h,1000000));}, @@ -121,11 +142,7 @@ export async function create({base,compiler}) { dispose(){if(closed)return;closed=true;cancel(Error('Disposed Zig session'));if(!busy)cleanup();}, }; try { - if(compiler){ - compilerHash=comp.compilerDescriptor(await fetchJSON('compiler.json',4096)).hash; - library=importBundle(await bundle.fetchBundle(new URL(`/_arboricx/bundle/hash/${compilerHash}`,base),compilerHash,bundle.COMPILER_BUNDLE_LIMIT)); - if(!library.has(comp.COMPILER_EXPORT))throw Error('Missing stable compiler entrypoint'); - } + if(compiler)await ensureCompiler(); return session; }catch(error){session.dispose();throw error;} } diff --git a/ext/zig/src/c_abi.zig b/ext/zig/src/c_abi.zig @@ -255,6 +255,7 @@ comptime { @export(&hostToText, .{ .name = "arb_host_to_text" }); @export(&hostPreview, .{ .name = "arb_host_preview" }); @export(&hostReduceUnbounded, .{ .name = "arb_host_reduce_unbounded" }); + @export(&hostSnapshotUnbounded, .{ .name = "arb_host_snapshot_unbounded" }); @export(&hostPreviewUnbounded, .{ .name = "arb_host_preview_unbounded" }); @export(&hostStats, .{ .name = "arb_host_stats" }); } @@ -325,3 +326,13 @@ fn hostPreviewUnbounded(ctx: *ArbCtx, root: Handle, limit: u32, out_ptr: **u8, o out_len.* = bytes.len; return .ok; } + +// Full bounded result transport, distinct from the 256-node preview API. +fn hostSnapshotUnbounded(ctx: *ArbCtx, root: Handle, limit: u32, out_ptr: **u8, out_len: *usize) callconv(.c) Status { + const node = ctx.handles.get(root) catch |err| return status(err); + ctx.prepare() catch |err| return status(err); + const bytes = @import("preview.zig").snapshotUnboundedWork(&ctx.arena, node, limit) catch |err| return status(err); + out_ptr.* = @ptrCast(bytes.ptr); + out_len.* = bytes.len; + return .ok; +} diff --git a/ext/zig/src/preview.zig b/ext/zig/src/preview.zig @@ -100,3 +100,74 @@ fn allocationProbe(allocator: std.mem.Allocator) !void { test "preview preserves sharing, survives GC, and cleans every allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationProbe, .{}); } + +// Result serialization has a larger, explicitly bounded transport profile. +// It does not relax the interactive preview's limit or bound reduction work. +pub fn snapshotUnboundedWork(arena: *Arena, root: u32, limit: u32) ![]u8 { + if (limit == 0 or limit > 100000) return error.InvalidData; + const allocator = arena.allocator; + const base = arena.roots.items.len; + try arena.roots.append(allocator, root); + defer arena.roots.shrinkRetainingCapacity(base); + var ids: std.AutoArrayHashMapUnmanaged(u32, void) = .empty; + defer ids.deinit(allocator); + var seen: std.AutoHashMapUnmanaged(u32, void) = .empty; + defer seen.deinit(allocator); + var pending: std.ArrayList(u32) = .empty; + defer pending.deinit(allocator); + var records: std.ArrayList(Record) = .empty; + defer records.deinit(allocator); + try ids.put(allocator, root, {}); + try pending.append(allocator, 0); + var truncated = false; + while (pending.pop()) |label| { + if (seen.contains(label)) continue; + if (records.items.len == limit) { truncated = true; break; } + const normal = try reduce.reduceUnbounded(ids.keys()[label], arena); + const node = arena.get(normal).*; + try seen.put(allocator, label, {}); + var record = Record{ .label = label, .kind = 0 }; + var children: [2]u32 = undefined; + const count: usize = switch (node) { + .leaf => 0, + .stem => |s| blk: { children[0] = s.child; break :blk 1; }, + .fork => |f| blk: { children = .{ f.left, f.right }; break :blk 2; }, + .app => unreachable, + }; + record.kind = @intCast(count); + for (children[0..count], 0..) |child, i| { + try ids.put(allocator, child, {}); + const key: u32 = @intCast(ids.getIndex(child).?); + if (i == 0) record.left = key else record.right = key; + } + if (count == 2) try pending.append(allocator, record.right); + if (count >= 1) try pending.append(allocator, record.left); + try records.append(allocator, record); + } + const bytes = try allocator.alloc(u8, 16 + records.items.len * 16); + const header = [_]u32{ 1, 0, @intCast(records.items.len), @intFromBool(truncated) }; + for (header, 0..) |word, i| std.mem.writeInt(u32, bytes[i * 4 ..][0..4], word, .little); + for (records.items, 0..) |record, i| { + const words = [_]u32{ record.label, record.kind, record.left, record.right }; + for (words, 0..) |word, j| std.mem.writeInt(u32, bytes[16 + i * 16 + j * 4 ..][0..4], word, .little); + } + return bytes; +} + +fn snapshotAllocationProbe(allocator: std.mem.Allocator) !void { + var arena = Arena.init(allocator); + defer arena.deinit(); + var root = try arena.alloc(.leaf); + for (0..600) |_| root = try arena.alloc(.{ .fork = .{ .left = root, .right = root } }); + const bytes = try snapshotUnboundedWork(&arena, root, 100000); + defer allocator.free(bytes); + try std.testing.expectEqual(@as(u32, 601), std.mem.readInt(u32, bytes[8..12], .little)); + try std.testing.expectEqual(@as(u32, 0), std.mem.readInt(u32, bytes[12..16], .little)); + const bounded = try snapshotUnboundedWork(&arena, root, 256); + defer allocator.free(bounded); + try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, bounded[12..16], .little)); + try std.testing.expectEqual(@as(usize, 0), arena.roots.items.len); +} +test "complete snapshot preserves sharing, bounds transport, and cleans allocation failures" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, snapshotAllocationProbe, .{}); +}