tricu

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

commit 3dfbafc869fefcb48b5a3d1738c6e756e87aba9a
parent edd33e6172a8236e0bc08250ec6c6031660097ac
Author: James Eversole <james@eversole.co>
Date:   Mon, 19 Oct 2026 11:19:12 -0500

Bundle browser entrypoints and polish the tricu homepage

Bundle production JavaScript at runtime boundaries with pinned esbuild, preserving lazy compiler/backend loading and immutable release URLs. Add cold-load measurements and bundled-release checks.

Add Linux WebKit coverage, reproducible icons and link-preview artwork, a clearer hero invitation, footer email feedback, and the title Arboricx: tricu and portable tree programs. Simplify documentation and test labels.

Validated both backends in Chromium, Firefox and Linux WebKit, 115 Node tests, generated-JS parity, artwork reproducibility and nix flake check.

Diffstat:
Mext/demo-site/README.md | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Aext/demo-site/artwork.nix | 8++++++++
Mext/demo-site/asset-release.py | 5+++--
Aext/demo-site/bundle-assets.py | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/dist/zig-helpers.js | 10++++++++++
Aext/demo-site/images/apple-touch-icon.png | 0
Aext/demo-site/images/favicon.png | 0
Aext/demo-site/images/icon.svg | 5+++++
Aext/demo-site/images/preview.png | 0
Aext/demo-site/images/preview.svg | 21+++++++++++++++++++++
Mext/demo-site/index.html | 21++++++++++++++++++---
Mext/demo-site/release.nix | 12+++++++++---
Aext/demo-site/src/zig-helpers.ts | 10++++++++++
Mext/demo-site/test/asset-cache-probe.mjs | 7++++---
Mext/demo-site/test/asset-release.py | 5+++--
Mext/demo-site/test/backend-benchmark.mjs | 2+-
Mext/demo-site/test/backend-browser.mjs | 2+-
Mext/demo-site/test/backend-server.mjs | 13+++++++++----
Aext/demo-site/test/backend-webkit.mjs | 34++++++++++++++++++++++++++++++++++
Aext/demo-site/test/cold-load-probe.mjs | 14++++++++++++++
Mext/demo-site/test/live-ux-probe.mjs | 6+++---
Mext/demo-site/test/local-module-probe.mjs | 8++++----
Mext/demo-site/test/module-probe.mjs | 4++--
Aext/demo-site/test/probe-api.mjs | 9+++++++++
Mext/demo-site/test/release.py | 53++++++++++++++++++++++++++++-------------------------
Mext/demo-site/test/site.test.mjs | 13++++++++++++-
Aext/demo-site/webkit.nix | 17+++++++++++++++++
Mext/wasm/site-session.mjs | 3+--
Mflake.nix | 2++
29 files changed, 398 insertions(+), 98 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -17,7 +17,7 @@ Program, Runtime, Settings and status, plus Stop while work is active. Results remain directly beneath the action even with the desktop inspector open. Settings dock on desktop and become a dismissible modal sheet on mobile, with Escape, focus containment/return, and inert background controls. The mobile source editor reaches both viewport edges; padding is inside the -editor, not an outer margin. Interpretation and execution mode remain visible +editor. Interpretation and execution mode remain visible outside settings. The footer credits Copyright 2026 James Eversole. On mobile, an explicit Evaluate/Show definition action can reveal the beginning @@ -36,9 +36,8 @@ Editable source, reference source, and the expression prompt distinguish comment strings/escapes, numbers, the kernel `t`, current keywords, module directives, operators and delimiters. Definition **declaration sites** have one dedicated color, including ordinary local equations; a simple contract name after `@` or -`=@` has an annotation color. References are not semantically resolved or colored -by binding identity. Declaration recognition is a conservative lexical/layout -heuristic, not a second compiler; compiler diagnostics remain authoritative. +`=@` has an annotation color. Declaration highlighting uses conservative lexical +and layout rules; compiler diagnostics resolve names and validate source. A collapsed caret immediately beside `()`, `[]` or `{}` highlights its matching partner. An unmatched delimiter gets a wavy underline. Strings and comments are @@ -46,8 +45,7 @@ excluded, including non-nesting block comments. Range selections do not highligh a pair. There is no automatic bracket insertion, indentation rewriting or completion. Editing and expression evaluation keep their existing behavior. -The small local `syntax.ts` renderer decorates native textareas rather than -replacing them with contenteditable or an external editor framework. Decorative +The local `syntax.ts` renderer decorates native textareas. Decorative mirrors are hidden from accessibility APIs and never interpret source as HTML. Selection, undo, resizing and scrolling remain native. During IME composition and in forced-colors mode, native text remains visible instead of the colored mirror. @@ -68,20 +66,17 @@ The JavaScript evaluator is retired as a user-selectable backend: its compilatio latency is unsuitable for the live-compilation workflow. Its independent graph, codec and compiler conformance coverage remains. Test-only JS Workers and their checkpoint protocol live under `src/conformance/` (generated into -`dist/conformance/`) and are excluded from the production release. The old -`dist/worker.js` and `dist/compiler-worker.js` entrypoints are removed, not aliases. -Shared JavaScript authentication, literal codecs and tree formatting remain in -use; there is no JS execution fallback. +`dist/conformance/`) and are excluded from the production release. Shared JavaScript authentication, literal codecs and tree formatting remain in +use alongside the native runtimes. The release packages the pinned Haskell reactor, loader, WASI shim and authenticated -compiler kit under `runtime/haskell/`. Loading and structured-argument runs do not download the compiler kit. -Expression evaluation loads it on demand, retaining the loaded definitions. Draft compilation checks the runtime's original compiler-bundle +compiler kit under `runtime/haskell/`. Expression evaluation and source compilation +load the compiler kit on demand, retaining the loaded definitions. Draft compilation checks the runtime's original compiler-bundle identity against `compiler.json`, then resolves explicit native imports or the selected empty, practical or default-contract standalone environment. Missing assets or mismatched pins are errors. Zig packages a freestanding reactor and private adapter under `runtime/zig/`. -It has no WASI imports. Its compiler session fetches the exact authenticated -compiler bundle selected by `compiler.json`, rather than loading Haskell's -prepared kit. Both use the same environment/profile pins and names. +It has no WASI imports. Its compiler session fetches the authenticated +compiler bundle selected by `compiler.json`. Both use the same environment/profile pins and names. Both runtimes use shared bundle authentication, literal validation, immutable stored-definition export and the same page IO protocol. `runtime-session.ts` @@ -98,21 +93,41 @@ Zig's site demands, bulk Unicode decoding and bounded previews explicitly select unbounded work, not a large substitute fuel value. The native ABI's finite-fuel operations remain available for native callers and negative-control tests. -WASM diagnostics report actual linear memory, not fabricated JS graph -counters or total browser RSS. Haskell's fixed RTS limits are 256 MiB heap and 8 MiB +WASM diagnostics report linear memory. Haskell's fixed RTS limits are 256 MiB heap and 8 MiB Haskell stack; both reactors cap linear memory at 512 MiB. Zig also has an 8 MiB WASM stack. Zig's strict scalar decoder rejects redundant high-zero numeral representations that the permissive JS text decoder tolerates; valid Unicode -scalar strings agree. These are implementation -constraints, not UI settings or a promise about total process memory. Exhaustion +scalar strings agree. These limits apply to the runtime's heap, stack and linear memory. Exhaustion or a fatal trap discards the session and requires explicit reinitialization. -The Haskell binary requires WebAssembly SIMD, supported starting with Chrome 91, -Firefox 89 and Safari/iOS 16.4. These are feature-support floors, not tested -minimum browser versions: integration is validated in Chromium 148 and Firefox -150; Safari/iOS remain unvalidated. Older clients can explicitly select -Zig if Haskell initialization fails; there is no silent fallback. Haskell uses -a bundled WASI shim and a nested module Worker, not browser-native WASI support. +The Haskell binary requires WebAssembly SIMD, available starting with Chrome 91, +Firefox 89 and Safari/iOS 16.4. Integration tests use Chromium 148, Firefox 150 +and Linux WebKit. Runtime selection is explicit; clients can select Zig if +Haskell initialization fails. Haskell uses a bundled WASI shim and a nested +module Worker. + +### Linux WebKit testing + +The pinned Linux WebKit runner exercises the built site: + +```sh +ARBORICX_SITE_ROOT=$(nix build .#arboricxSite --no-link --print-out-paths) \ +TEST_IDE=1 TEST_BACKEND=haskell-wasm \ + nix run .#arboricxWebkitCheck +``` + +Repeat with `TEST_BACKEND=zig-wasm` and with `TEST_MODULES=1`, +`TEST_LOCAL_MODULES=1`, or `TEST_ASSET_CACHE=1` in place of `TEST_IDE=1`. +The IDE, complete demo/module, local-file and cache suites pass on both backends +with Playwright 1.59.1 / WebKit build 2272. The runner also checks layout at +320, 390 and 1280 pixels after textarea mirror resize observers settle. +`SCREENSHOT=/tmp/arboricx.png` saves a screenshot at each width. + +Each run uses a fresh, temporary disk-backed browser profile, matching the +Chromium/Firefox harnesses. Private ephemeral WebKit contexts can refetch large +WASM assets despite immutable headers; they are a different cache experiment. +The Nix wrapper supplies the matching driver, WebKit and patched libraries. +Run from the repository root. ## Programs and controls @@ -666,7 +681,7 @@ result-site-deps/bin/arboricx-seed-site "${REGISTRY_STORE:?select an absolute pe ``` `arboricxSite` contains `index.html` and a complete asset tree under -`assets/<release-sha256>/`: CSS, freshly compiled JS, both WASM runtimes and their +`assets/<release-sha256>/`: CSS, bundled JS, both WASM runtimes and their support files, `catalogue.json`, `packages.json`, `environments.json`, and `compiler.json`. `assets.json` records each file's path, size and SHA-256. The release identity hashes that sorted manifest with the `arboricx.site-assets.v1` @@ -701,6 +716,70 @@ registry hash responses too. Registry health is `/_arboricx/health`; a `/health` An ordinary static-only server can show the ecosystem but cannot run these demos or the compiler without the registry route. +### Icons and link previews + +The page provides a canonical homepage URL, generic Open Graph metadata, a +1200×630 PNG preview, SVG/PNG favicons and a 180×180 touch icon. Artwork uses the +site's Leaf/Stem/Fork shapes and palette. The release places these images in the +same immutable asset namespace as its runtime files and rewrites HTML references; +the preview's absolute URL targets `https://arbori.cx/`. Alternate deployments +should update that origin and the canonical/preview metadata together. + +Edit `images/icon.svg` or `images/preview.svg`, then regenerate the PNGs using the +pinned renderer and fonts: + +```sh +artwork=$(nix build .#arboricxArtwork --no-link --print-out-paths) +cp "$artwork"/*.png ext/demo-site/images/ +``` + +The site build verifies the checked-in PNGs against these generated assets. +Footer feedback uses a direct email link. + +### Production JavaScript bundles + +The Nix release typechecks TypeScript, verifies the checked-in development JS, +and bundles it with the pinned esbuild. Seven ESM entrypoints preserve the runtime +boundaries and the directory bases used by `import.meta.url`: + +- `dist/app.js`: page UI. +- `dist/wasm-worker.js`: native-runtime session driver. +- `runtime/haskell/client.mjs` and `runtime/haskell/worker.mjs`: Haskell client and + its nested runtime Worker. +- `runtime/zig/site-session.mjs` and `dist/zig-helpers.js`: on-demand Zig adapter + and shared helpers. +- `dist/local-module-worker.js`: on-demand local-file validation. + +Each file includes its static dependencies, with tree shaking and readable output. +Shared chunks are deliberately avoided across Worker boundaries. The default +homepage loads four JS files; WASM binaries and authenticated compiler data retain +separate downloads. Compiler initialization, the unused backend and local-file +validation remain lazy. The complete bundled tree receives the release identity +used for immutable caching. + +Development keeps individual modules in `dist/`. `nix-support/bundle-graphs.json` +records esbuild inputs/outputs for inspection; the release check verifies the +entrypoint set and self-contained static imports. The loopback browser harness +serves a separate test API facade from `nix-support/probe-api.js` for adapter and +golden-value tests. That facade is excluded from the versioned production graph; +page tests exercise the shipped bundles directly. + +Measure a fresh homepage with production-like cache headers: + +```sh +ARBORICX_SITE_ROOT=$(nix build .#arboricxSite --no-link --print-out-paths) \ +TEST_COLD_LOAD=1 TEST_ASSET_CACHE=1 BENCHMARK_REPORT=/tmp/arboricx-cold.json \ + nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ + node ext/demo-site/test/backend-browser.mjs unused +``` + +The report counts successful homepage/runtime requests at the server, excludes +probe traffic, and records uncompressed response-body bytes and time to Ready. +It checks that compiler assets, Zig and local validation stay lazy. Compare repeated +fresh-profile runs; localhost timings describe harness overhead rather than WAN +latency, and deployed gzip changes transferred bytes. Firefox uses the matching +`backend-firefox.mjs` harness and `nixpkgs#firefox`. + ### Immutable asset caching For nginx, add this map in the `http` context: @@ -1003,7 +1082,7 @@ Use the published manifest pins with the native source import syntax above. ## Build / regenerate / validate -No npm installation. Tooling comes from the repository's locked nixpkgs: +Tooling comes from the repository's locked nixpkgs: ```sh ./ext/demo-site/build.sh # strict TS → dist/*.js (TypeScript 5.9.3) @@ -1057,13 +1136,12 @@ nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 \ nix flake check ``` -Generated JS is committed so deployment requires no tools. This module's source -and generated output are covered by the explicit parity check. The graph, -package and browser checks are **opt-in**, not added to the fast host gate. +Generated development JS is committed and covered by the explicit parity check. +Nix builds the bundled release. Graph, package and browser checks have explicit +validation commands. Chromium CDP tests require Node with global WebSocket support and use a temporary profile plus `--no-sandbox` for container compatibility; only run them against -this trusted local fixture site. Safari and real mobile performance are -not yet validated. Browser tests exercise a local registry, not production. +this trusted local fixture site. Browser tests exercise a local registry. The backend-selector suites serve the actual packaged page at a nested URL, with a local authenticated registry and packaged runtime assets. They check @@ -1072,8 +1150,7 @@ repeated retained runs, output-error recovery, missing-runtime and compiler-pin failure without fallback, Stop/recompile (including non-returning reduction), stepping, stale/wrong-kind/invalid-line resume rejection and backend-switch isolation. Invalid responses do not consume a pending request ID. Both WASM -backends have passed in Chromium 148 and Firefox 150 on Linux. They are not broader -claims about other engines, mobile devices or Internet download latency. The +backends pass the integration suites in Chromium 148, Firefox 150 and Linux WebKit. The Haskell embedding's independent 117-application parity suite remains in `ext/haskell-wasm`; it is distinct from these real-page integration checks. The Zig extension of the page suite also checks all 117 complete published @@ -1081,18 +1158,16 @@ 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 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. +Complete bounded observation supplies tree-file result interpretations and +conformance tests. Scalar interpretations use their bounded codecs. Parity checks +exercise the common site capabilities through each backend's private protocol. ### 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. +explicit compilation and structured-argument Worker calls. -Use a built site, not the prototype Worker. The harness alternates backend order +Use a built site. 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. Each compilation is followed by 21 retained applications; the first is recorded @@ -1236,7 +1311,7 @@ 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 +edge/focus behavior using the packaged compiler and native evaluators. The former tab-oriented UI expectations were retired; lower-level codec, package, security and runtime conformance controls remain. diff --git a/ext/demo-site/artwork.nix b/ext/demo-site/artwork.nix @@ -0,0 +1,8 @@ +{ pkgs }: +pkgs.runCommand "arboricx-artwork" { nativeBuildInputs = [ pkgs.resvg ]; } '' + mkdir -p $out + resvg --skip-system-fonts --use-fonts-dir ${pkgs.dejavu_fonts}/share/fonts/truetype \ + ${./images/preview.svg} $out/preview.png + resvg --skip-system-fonts --width 32 --height 32 ${./images/icon.svg} $out/favicon.png + resvg --skip-system-fonts ${./images/icon.svg} $out/apple-touch-icon.png +'' diff --git a/ext/demo-site/asset-release.py b/ext/demo-site/asset-release.py @@ -8,7 +8,7 @@ from pathlib import Path def freeze(root): root = Path(root) - names = ['dist', 'runtime', 'style.css', 'catalogue.json', 'compiler.json', + names = ['dist', 'runtime', 'images', 'style.css', 'catalogue.json', 'compiler.json', 'packages.json', 'environments.json'] files = sorted(p for name in names for p in ((root / name).rglob('*') if (root / name).is_dir() else [root / name]) @@ -19,11 +19,12 @@ def freeze(root): version = hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() prefix = 'assets/' + version html = (root / 'index.html').read_text() - for name in ['dist/app.js', 'style.css']: + for name in ['dist/app.js', 'style.css', 'images/favicon.png', 'images/icon.svg', 'images/apple-touch-icon.png']: old = './' + name if html.count('"' + old + '"') != 1: raise ValueError('Expected one HTML entrypoint: ' + old) html = html.replace('"' + old + '"', '"./' + prefix + '/' + name + '"') + html = html.replace('https://arbori.cx/images/preview.png', 'https://arbori.cx/' + prefix + '/images/preview.png') destination = root / prefix destination.mkdir(parents=True, exist_ok=False) for name in names: diff --git a/ext/demo-site/bundle-assets.py b/ext/demo-site/bundle-assets.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Bundle production entrypoints while preserving their import.meta.url bases.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess + +ENTRIES = ['dist/app.js', 'dist/wasm-worker.js', 'dist/local-module-worker.js', + 'dist/zig-helpers.js', 'runtime/zig/site-session.mjs', + 'runtime/haskell/client.mjs', 'runtime/haskell/worker.mjs'] + + +def bundle(root, probe): + root = root.resolve() + support = root / 'nix-support' + support.mkdir(exist_ok=True) + shutil.copyfile(root / 'runtime/haskell/pins.mjs', support / 'haskell-pins.mjs') + shutil.copyfile(probe, root / 'dist/probe-api.js') + graphs = {} + for entry in [*ENTRIES, 'dist/probe-api.js']: + output = 'bundled/' + entry + subprocess.run(['esbuild', entry, '--bundle', '--format=esm', + '--platform=browser', '--target=es2020', + '--legal-comments=inline', '--outfile=' + output, + '--metafile=graph.json'], cwd=root, check=True) + graph = json.loads((root / 'graph.json').read_text()) + # Computed URL imports retain explicit lazy runtime boundaries. All + # statically resolved imports must be contained in their entrypoint. + assert all(not row['imports'] for row in graph['outputs'].values()) + graphs[entry] = graph + (support / 'bundle-graphs.json').write_text(json.dumps(graphs, indent=2) + '\n') + (root / 'graph.json').unlink() + for name in ['dist', 'runtime']: + for path in (root / name).rglob('*'): + if path.suffix in ['.js', '.mjs']: + path.unlink() + for entry in ENTRIES: + shutil.move(root / 'bundled' / entry, root / entry) + shutil.move(root / 'bundled/dist/probe-api.js', support / 'probe-api.js') + shutil.rmtree(root / 'bundled') + # Empty module directories would otherwise imply unshipped entrypoints. + for path in sorted((root / 'runtime').rglob('*'), reverse=True): + if path.is_dir() and not any(path.iterdir()): + path.rmdir() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path) + parser.add_argument('probe', type=Path) + args = parser.parse_args() + bundle(args.root, args.probe) diff --git a/ext/demo-site/dist/zig-helpers.js b/ext/demo-site/dist/zig-helpers.js @@ -0,0 +1,10 @@ +// Lazy shared-helper entrypoint for the Zig adapter; bundled as one release file. +export * as bundle from './bundle.js'; +export * as codec from './codecs.js'; +export * as comp from './compiler.js'; +export * as contracts from './contracts.js'; +export * as packages from './packages.js'; +export * as expressions from './expression.js'; +export * as treeResults from './tree-result.js'; +export * as modules from './module-stage.js'; +export * as moduleBundle from './module-bundle.js'; diff --git a/ext/demo-site/images/apple-touch-icon.png b/ext/demo-site/images/apple-touch-icon.png Binary files differ. diff --git a/ext/demo-site/images/favicon.png b/ext/demo-site/images/favicon.png Binary files differ. diff --git a/ext/demo-site/images/icon.svg b/ext/demo-site/images/icon.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180"> + <rect width="180" height="180" rx="36" fill="#f5f2ea"/> + <path d="M90 45 48 123M90 45l42 78" stroke="#235a43" stroke-width="10" stroke-linecap="round"/> + <g fill="#235a43"><circle cx="90" cy="45" r="17"/><circle cx="48" cy="123" r="17"/><circle cx="132" cy="123" r="17"/></g> +</svg> diff --git a/ext/demo-site/images/preview.png b/ext/demo-site/images/preview.png Binary files differ. diff --git a/ext/demo-site/images/preview.svg b/ext/demo-site/images/preview.svg @@ -0,0 +1,21 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630"> + <rect width="1200" height="630" fill="#f5f2ea"/> + <path d="M64 96h1072M64 554h1072" stroke="#d8dcd0" stroke-width="2"/> + <g font-family="DejaVu Sans" fill="#202d29"> + <text x="64" y="68" font-size="28" font-weight="bold">arbori.cx</text> + <text x="1136" y="68" text-anchor="end" font-size="20" fill="#626d65">tricu / Tree Calculus</text> + <text x="64" y="226" font-size="64" font-weight="bold" letter-spacing="-2">Portable programs.</text> + <text x="64" y="309" font-size="64" font-weight="bold" letter-spacing="-2" fill="#235a43">All trees.</text> + <text x="64" y="398" font-size="26" fill="#626d65">Write, run and inspect programs</text> + <text x="64" y="438" font-size="26" fill="#626d65">built from three simple shapes.</text> + <text x="64" y="596" font-size="20" fill="#626d65">An interactive language and portable program playground.</text> + </g> + <g stroke="#235a43" stroke-width="5" fill="none"> + <path d="M940 210v70M1045 360l-48 80m48-80 48 80"/> + </g> + <g fill="#235a43"> + <circle cx="820" cy="300" r="16"/> + <circle cx="940" cy="210" r="16"/><circle cx="940" cy="280" r="16"/> + <circle cx="1045" cy="360" r="16"/><circle cx="997" cy="440" r="16"/><circle cx="1093" cy="440" r="16"/> + </g> +</svg> diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -3,7 +3,22 @@ <head> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="description" content="Explore Tree Calculus through tricu: familiar syntax, portable Arboricx packages, interactive IO and a self-hosting compiler."> - <title>Arboricx: portable programs, all trees</title> + <link rel="canonical" href="https://arbori.cx/"> + <meta name="theme-color" content="#f5f2ea"> + <link rel="icon" href="./images/favicon.png" type="image/png" sizes="32x32"> + <link rel="icon" href="./images/icon.svg" type="image/svg+xml"> + <link rel="apple-touch-icon" href="./images/apple-touch-icon.png" sizes="180x180"> + <meta property="og:type" content="website"> + <meta property="og:site_name" content="Arboricx"> + <meta property="og:title" content="Portable programs. All trees."> + <meta property="og:description" content="Write familiar-looking code and explore how it becomes a tree you can run, inspect, and share."> + <meta property="og:url" content="https://arbori.cx/"> + <meta property="og:image" content="https://arbori.cx/images/preview.png"> + <meta property="og:image:type" content="image/png"> + <meta property="og:image:width" content="1200"> + <meta property="og:image:height" content="630"> + <meta property="og:image:alt" content="Arboricx: Portable programs. All trees. Leaf, Stem and Fork diagrams on a warm cream background."> + <title>Arboricx: tricu and portable tree programs</title> <link rel="stylesheet" href="./style.css"><script type="module" src="./dist/app.js"></script> </head> <body> @@ -12,7 +27,7 @@ <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> + <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. Write familiar-looking code and explore how it becomes a tree you can run, inspect, and share.</p> <div class="shapes" aria-label="The three tree shapes"> <div><svg viewBox="0 0 100 80" aria-hidden="true"><circle cx="50" cy="25" r="7"/></svg><strong>Leaf</strong><code>t</code></div> <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> @@ -99,6 +114,6 @@ <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> </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> + <footer class="wrap" data-inspector-background><p>Copyright 2026 <a href="https://eversole.co">James Eversole</a> · <a href="mailto:james@eversole.co">Email feedback</a></p></footer> </body> </html> diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix @@ -8,9 +8,10 @@ let top = builtins.head (pkgs.lib.splitString "/" relative); in builtins.elem top [ "src" "dist" "index.html" "style.css" "catalogue.json" - "compiler.json" "packages.json" "environments.json" "tsconfig.json" + "images" "compiler.json" "packages.json" "environments.json" "tsconfig.json" ]; }; + artwork = import ./artwork.nix { inherit pkgs; }; dependencies = pkgs.runCommand "arboricx-site-deps" { nativeBuildInputs = [ pkgs.python3 pkgs.makeWrapper ]; } '' @@ -24,7 +25,7 @@ let --add-flags "$out/share/arboricx-site-deps/seed.py --deps $out/share/arboricx-site-deps" ''; site = pkgs.runCommand "arboricx-site" { - nativeBuildInputs = [ pkgs.typescript pkgs.python3 ]; + nativeBuildInputs = [ pkgs.typescript pkgs.python3 pkgs.esbuild ]; # Retain a matching dependency pack for every retained site release/rollback. passthru.dependencies = dependencies; } '' @@ -33,6 +34,10 @@ let diff -ru dist "$TMPDIR/dist" mkdir -p $out cp index.html style.css catalogue.json compiler.json packages.json environments.json $out/ + cp -r images $out/images + for name in preview.png favicon.png apple-touch-icon.png; do + cmp images/$name ${artwork}/$name + done cp -r "$TMPDIR/dist" $out/dist # JS evaluator drivers/checkpoints are conformance tools, not a site backend. rm -r $out/dist/conformance @@ -43,7 +48,8 @@ let mkdir -p $out/runtime/haskell cp ${haskellRuntime}/{client.mjs,worker.mjs,bridge.mjs,pins.mjs,reactor.wasm} $out/runtime/haskell/ cp -r ${haskellRuntime}/{shim,data,site} $out/runtime/haskell/ - chmod u+w $out/index.html + chmod -R u+w $out + python3 ${./bundle-assets.py} $out ${./test/probe-api.mjs} python3 ${./asset-release.py} $out # Keep the dependency pack in this output's Nix closure, not its webroot. mkdir -p $out/nix-support diff --git a/ext/demo-site/src/zig-helpers.ts b/ext/demo-site/src/zig-helpers.ts @@ -0,0 +1,10 @@ +// Lazy shared-helper entrypoint for the Zig adapter; bundled as one release file. +export * as bundle from './bundle.js'; +export * as codec from './codecs.js'; +export * as comp from './compiler.js'; +export * as contracts from './contracts.js'; +export * as packages from './packages.js'; +export * as expressions from './expression.js'; +export * as treeResults from './tree-result.js'; +export * as modules from './module-stage.js'; +export * as moduleBundle from './module-bundle.js'; diff --git a/ext/demo-site/test/asset-cache-probe.mjs b/ext/demo-site/test/asset-cache-probe.mjs @@ -17,7 +17,7 @@ export async function run(control,backend='haskell-wasm'){ const code=paths=>paths.filter(p=>p.startsWith(base.pathname)&&/\.(?:js|mjs|wasm)(?:\?|$)/.test(p)); const evaluate=async()=>{input('expression-editor','42');change('output-type','number');change('mode','pure');el('run').click();await status('Finished');assert(el('result').textContent==='42','Evaluation works');}; await evaluate(); // Warm the compiler and expression path too. - const warm=code(await requests());assert(warm.length>10,'Network observed the initial dependency graph'); + const warm=code(await requests());assert(warm.length>=5,'Network observed the initial dependency graph'); for(const name of ['rgb','microscope','intro']){ change('program-picker','demo:'+name);await status('Ready');await evaluate(); } @@ -35,10 +35,11 @@ export async function run(control,backend='haskell-wasm'){ const open=()=>{const transfer=new DataTransfer();transfer.items.add(new File([bytes],'module.arboricx'));el('local-module-file').files=transfer.files;el('local-module-file').dispatchEvent(new Event('change'));}; const checked=async()=>{for(let i=0;i<3000;i++){if(!el('local-module-file').disabled&&el('local-module-list').children.length===1)return;await delay(20);}throw Error('Local validation failed');}; // Check runtime code reuse before warming the additional validator entrypoint. - assert(JSON.stringify(code(await requests()))===JSON.stringify(compiled),'Fresh compile/Stop Workers reuse cached code'); + const afterStop=code(await requests()); + assert(JSON.stringify(afterStop)===JSON.stringify(compiled),'Fresh compile/Stop Workers reuse cached code: '+JSON.stringify(afterStop.slice(compiled.length))); open();await checked();const localWarm=code(await requests());open();await checked(); const final=code(await requests()); - assert(JSON.stringify(final)===JSON.stringify(localWarm),'Local validator Workers reuse cached code'); + assert(JSON.stringify(final)===JSON.stringify(localWarm),'Local validator Workers reuse cached code: '+JSON.stringify(final.slice(localWarm.length))); const counts=new Map();for(const p of final)counts.set(p,(counts.get(p)||0)+1); assert([...counts.values()].every(n=>n===1),'Demo changes and fresh Workers fetch each code URL once: '+JSON.stringify([...counts].filter(([,n])=>n>1))); diff --git a/ext/demo-site/test/asset-release.py b/ext/demo-site/test/asset-release.py @@ -16,11 +16,12 @@ with tempfile.TemporaryDirectory() as temporary: 'runtime/haskell/shim/index.js', 'runtime/haskell/reactor.wasm', 'runtime/haskell/data/compiler.dag', 'runtime/zig/site-session.mjs', 'runtime/zig/arboricx.wasm', 'catalogue.json', 'packages.json', - 'compiler.json', 'environments.json', 'style.css'] + 'compiler.json', 'environments.json', 'style.css', 'images/icon.svg', + 'images/favicon.png', 'images/apple-touch-icon.png', 'images/preview.png'] def build(number, changed=None, reverse=False): root = parent / str(number) root.mkdir() - (root / 'index.html').write_text('<link href="./style.css"><script type="module" src="./dist/app.js"></script>') + (root / 'index.html').write_text('<link href="./style.css"><script type="module" src="./dist/app.js"></script>' + ''.join('<link href="./images/'+n+'">' for n in ['icon.svg','favicon.png','apple-touch-icon.png']) + '<meta content="https://arbori.cx/images/preview.png">') for name in reversed(entries) if reverse else entries: file = root / name file.parent.mkdir(parents=True, exist_ok=True) diff --git a/ext/demo-site/test/backend-benchmark.mjs b/ext/demo-site/test/backend-benchmark.mjs @@ -3,7 +3,7 @@ export async function run(control,backend,memoryOnly=false){ const base=new URL('../',document.querySelector('script[type=module][src]').src); const [fixtures,catalogue,{Graph},{view}]=await Promise.all([ fetch('/zig-fixtures.json').then(r=>r.json()),fetch(new URL('catalogue.json',base)).then(r=>r.json()), - import(new URL('dist/graph.js',base)),import(new URL('dist/codecs.js',base)), + import(new URL('dist/probe-api.js',base)).then(m=>m.graph),import(new URL('dist/probe-api.js',base)).then(m=>m.codecs), ]); const assert=(ok,message)=>{if(!ok)throw Error(message);}; const mark=async name=>{await fetch(control+'/phase',{method:'POST',body:JSON.stringify({name})});}; diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -53,7 +53,7 @@ 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_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':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}))`); + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':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]){ diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -8,13 +8,16 @@ export async function startSiteServer(directory,autoRun=false) { const root=resolve(process.env.ARBORICX_SITE_ROOT||'ext/demo-site'),runtime=resolve(directory),registry=resolve('ext/demo-site/bundles'); const assetManifest=await readFile(resolve(root,'assets.json'),'utf8').then(JSON.parse).catch(()=>null); const assetRoot=assetManifest?resolve(root,assetManifest.base):root; - const control='/test-'+randomBytes(24).toString('hex'),requests=[],marks=[]; + const control='/test-'+randomBytes(24).toString('hex'),requests=[],marks=[],transfers=[]; let failRuntime=false,compilerMismatch=false,moduleMissing=null,moduleCorrupt=null,moduleManifest=null,deliver,fail; const completion=new Promise((resolve,reject)=>{deliver=resolve;fail=reject;});completion.catch(()=>{}); const server=createServer(async(req,res)=>{ try { const url=new URL(req.url,'http://localhost'),path=url.pathname;requests.push(process.env.TEST_ASSET_CACHE?path+url.search:path); res.setHeader('Cache-Control','no-store'); + if(path===control+'/transfers'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(transfers));return;} + if(path==='/nested/site/'+(assetManifest?.base??'')+'dist/probe-api.js'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(assetManifest?resolve(root,'nix-support/probe-api.js'):new URL('./probe-api.mjs',import.meta.url)));return;} + if(path==='/cold-load-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./cold-load-probe.mjs',import.meta.url)));return;} if(path===control+'/requests'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(requests));return;} if(path===control+'/fault'||path===control+'/report'||path===control+'/phase') { if(req.method!=='POST')throw Error('method'); @@ -62,15 +65,17 @@ export async function startSiteServer(directory,autoRun=false) { file=resolve(base,relative.startsWith('runtime/haskell/')?relative.slice('runtime/haskell/'.length):relative); if(!file.startsWith(base+sep))throw Error('path'); } - 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'); + res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json','.css':'text/css','.svg':'image/svg+xml','.png':'image/png'})[extname(file)]??'application/octet-stream'); let bytes=await readFile(file); if(moduleCorrupt&&path==='/_arboricx/bundle/hash/'+moduleCorrupt){bytes=Buffer.from(bytes);bytes[0]^=1;} + if(process.env.TEST_COLD_LOAD&&path==='/nested/site/index.html')bytes=Buffer.from(bytes.toString().replace('<head>',`<head><script>new MutationObserver((_,observer)=>{if(document.getElementById('status')?.textContent==='Ready'){globalThis.__readyMs=performance.now();observer.disconnect();}}).observe(document.documentElement,{childList:true,subtree:true,characterData:true});</script>`)); if(autoRun&&path==='/nested/site/index.html'&&!new URL(req.url,'http://localhost').searchParams.has('noProbe'))bytes=Buffer.from(bytes.toString()+`<script type="module"> - try{const m=await import(${JSON.stringify(process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':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)})});} + try{const m=await import(${JSON.stringify(process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':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)+'\\n'+(e.stack||'')})});} </script>`); if(process.env.TEST_ASSET_CACHE && ['/nested/site/index.html','/nested/site/assets.json'].includes(path))res.setHeader('Cache-Control','no-cache'); if(process.env.TEST_ASSET_CACHE && assetManifest && path.startsWith('/nested/site/'+assetManifest.base))res.setHeader('Cache-Control','public, max-age=31536000, immutable'); + transfers.push({path:path+url.search,bytes:bytes.length}); res.end(bytes); }catch{res.writeHead(404);res.end();} }); diff --git a/ext/demo-site/test/backend-webkit.mjs b/ext/demo-site/test/backend-webkit.mjs @@ -0,0 +1,34 @@ +// Exercise the built site with Linux WebKit. +import assert from 'node:assert/strict'; +import {writeFile,mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {startSiteServer} from './backend-server.mjs'; +const {webkit}=await import(process.env.PLAYWRIGHT_MODULE); +const {server,url,completion}=await startSiteServer(process.argv[2]||'unused',true); +const profile=await mkdtemp(join(tmpdir(),'tricu-webkit-')); +let browser,timer; +try { + // Match the other browser harnesses: a fresh disk-backed profile per run. + browser=await webkit.launchPersistentContext(profile,{headless:true,viewport:{width:1280,height:900}}); + const page=await browser.newPage(),errors=[]; + page.on('pageerror',e=>errors.push(String(e))); + await page.goto(url); + const report=await Promise.race([completion,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('WebKit probe timeout')),240000);})]); + assert.deepEqual(errors,[]); + for(const width of [320,390,1280]){ + await page.setViewportSize({width,height:844}); + // Let ResizeObserver update the textarea's decorative mirror geometry. + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth ? [...document.querySelectorAll('body *')].filter(e=>{const r=e.getBoundingClientRect();return r.width&&r.right>innerWidth+1;}).map(e=>({tag:e.tagName,id:e.id,class:e.className,width:e.getBoundingClientRect().width,text:e.textContent.slice(0,100)})) : []); + assert.deepEqual(overflow,[],'Viewport overflow at '+width); + if(process.env.SCREENSHOT){await page.evaluate(()=>scrollTo({top:0,behavior:'instant'}));await page.screenshot({path:process.env.SCREENSHOT.replace(/\.png$/, '-'+width+'.png')});} + } + report.engine='Linux WebKit'; + if(process.env.BENCHMARK_REPORT)await writeFile(process.env.BENCHMARK_REPORT,JSON.stringify(report,null,2)+'\n'); + + console.log('PASS '+report.engine+': '+report.checks.join(' / ')); +} finally { + clearTimeout(timer);await browser?.close();server.closeAllConnections();await new Promise(r=>server.close(r)); + await rm(profile,{recursive:true,force:true}); +} diff --git a/ext/demo-site/test/cold-load-probe.mjs b/ext/demo-site/test/cold-load-probe.mjs @@ -0,0 +1,14 @@ +export async function run(control){ + const delay=ms=>new Promise(r=>setTimeout(r,ms)); + for(let i=0;document.getElementById('status').textContent!=='Ready';i++){if(i>4500)throw Error('Homepage failed to become Ready');await delay(20);} + const transfers=(await(await fetch(control+'/transfers')).json()).filter(r=>r.path.startsWith('/nested/site/')||r.path.startsWith('/_arboricx/')); + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const compiler=(await(await fetch(new URL('compiler.json',base))).json()).hash; + if(transfers.some(r=>r.path==='/_arboricx/bundle/hash/'+compiler))throw Error('Homepage fetched the compiler bundle'); + const manifest=await(await fetch(new URL('assets.json',location.href))).json(); + const bundled=manifest.files.filter(r=>/\.(?:m?js)$/.test(r.path)).length===7; + const js=transfers.filter(r=>/\.(?:m?js)(?:\?|$)/.test(r.path)); + if(bundled&&js.length!==4)throw Error('Bundled homepage must load four JS entrypoints'); + if(transfers.some(r=>/compiler\.(?:json|dag)|environment\.tsv|runtime\/zig|local-module-worker/.test(r.path)))throw Error('Homepage fetched a lazy asset'); + return {userAgent:navigator.userAgent,checks:['cold homepage ready; compiler, Zig and local validation stay lazy'],requests:transfers.length,jsRequests:js.length,bytes:transfers.reduce((n,r)=>n+r.bytes,0),jsBytes:js.reduce((n,r)=>n+r.bytes,0),readyMs:globalThis.__readyMs,transfers}; +} diff --git a/ext/demo-site/test/live-ux-probe.mjs b/ext/demo-site/test/live-ux-probe.mjs @@ -18,9 +18,9 @@ export async function run(control,backend='haskell-wasm',alreadyUsed=false){ 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 base=new URL('../',document.querySelector('script[type=module][src]').src); - const {Graph}=await import(new URL('dist/graph.js',base)); - const {view,readList}=await import(new URL('dist/codecs.js',base)); - const {printLiteral}=await import(new URL('dist/literal-input.js',base)); + const {Graph}=(await import(new URL('dist/probe-api.js',base))).graph; + const {view,readList}=(await import(new URL('dist/probe-api.js',base))).codecs; + const {printLiteral}=(await import(new URL('dist/probe-api.js',base))).literalInput; 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); diff --git a/ext/demo-site/test/local-module-probe.mjs b/ext/demo-site/test/local-module-probe.mjs @@ -8,10 +8,10 @@ const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('inpu const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; export async function run(control,backend){ const base=new URL('../',document.querySelector('script[type=module][src]').src); - const {createRuntimeSession}=await import(new URL('dist/runtime-session.js',base)); - const {validateLocalModule}=await import(new URL('dist/local-modules.js',base)); - const {encodeModule}=await import(new URL('dist/module-bundle.js',base)); - const {decodeBundle}=await import(new URL('dist/bundle.js',base)); + const {createRuntimeSession}=(await import(new URL('dist/probe-api.js',base))).runtimeSession; + const {validateLocalModule}=(await import(new URL('dist/probe-api.js',base))).localModules; + const {encodeModule}=(await import(new URL('dist/probe-api.js',base))).moduleBundle; + const {decodeBundle}=(await import(new URL('dist/probe-api.js',base))).bundle; const index=await(await fetch(new URL('packages.json',base))).json(); const math=index.releases.find(r=>r.name==='tricu-lib-native-math'&&r.version==='0.1.0'); const source=`!import ${math.manifest} as N\n!export echo\n!export plus\n!export value\nprivate = 9\necho x = x\nplus x = N.add x private\nvalue = 42\nq x = x x\nhidden = q q`; diff --git a/ext/demo-site/test/module-probe.mjs b/ext/demo-site/test/module-probe.mjs @@ -9,8 +9,8 @@ const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('inpu const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; export async function run(control,backend='haskell-wasm'){ const base=new URL('../',document.querySelector('script[type=module][src]').src); - const {createRuntimeSession}=await import(new URL('dist/runtime-session.js',base)); - const {packageIndex,packageReleases}=await import(new URL('dist/packages.js',base)); + const {createRuntimeSession}=(await import(new URL('dist/probe-api.js',base))).runtimeSession; + const {packageIndex,packageReleases}=(await import(new URL('dist/probe-api.js',base))).packages; const releases=packageIndex(await(await fetch(new URL('packages.json',base))).json()); const lib=name=>packageReleases(releases,'tricu-lib-'+name)[0]; const math=lib('native-math'),foundation=lib('base'),io=lib('io'),modules=lib('source-module'); diff --git a/ext/demo-site/test/probe-api.mjs b/ext/demo-site/test/probe-api.mjs @@ -0,0 +1,9 @@ +// Test-only facade served at dist/probe-api.js by the loopback harness. +export * as runtimeSession from './runtime-session.js'; +export * as packages from './packages.js'; +export * as localModules from './local-modules.js'; +export * as moduleBundle from './module-bundle.js'; +export * as bundle from './bundle.js'; +export * as graph from './graph.js'; +export * as codecs from './codecs.js'; +export * as literalInput from './literal-input.js'; diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py @@ -21,9 +21,15 @@ def main(): assert assets['version'] == hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() assert assets['base'] == 'assets/' + assets['version'] + '/' html = (args.site / 'index.html').read_text() - for entry in ['dist/app.js', 'style.css']: + for entry in ['dist/app.js', 'style.css', 'images/favicon.png', 'images/icon.svg', 'images/apple-touch-icon.png']: assert '"./' + assets['base'] + entry + '"' in html + assert 'https://arbori.cx/' + assets['base'] + 'images/preview.png' in html + for name, size in [('preview.png', (1200, 630)), ('favicon.png', (32, 32)), ('apple-touch-icon.png', (180, 180))]: + png = (args.site / assets['base'] / 'images' / name).read_bytes() + assert png[:8] == b'\x89PNG\r\n\x1a\n' + assert (int.from_bytes(png[16:20], 'big'), int.from_bytes(png[20:24], 'big')) == size assert not (args.site / 'dist').exists() + support = args.site / 'nix-support' args.site = args.site / assets['base'] assert sorted(p.relative_to(args.site).as_posix() for p in args.site.rglob('*') if p.is_file()) == [r['path'] for r in assets['files']] for row in assets['files']: @@ -52,34 +58,31 @@ def main(): assert not list(args.site.rglob('conformance')) for retired in ['worker.js', 'compiler-worker.js']: assert not (args.site / 'dist' / retired).exists() - for script in ['wasm-worker.js']: - source = (args.site / 'dist' / script).read_text() - assert './published-bundle.js' in source and 'bundles/' not in source - # Resolution moved inside the runtime Worker so headers decide whether a - # legacy profile is needed. Check the actual download owners, not the old - # Haskell session forwarding adapter, while retaining the no-static-fallback - # control for every production route. - for script in ['dist/published-bundle.js', 'dist/module-resolver.js', + entries = {'dist/app.js', 'dist/wasm-worker.js', 'dist/local-module-worker.js', + 'dist/zig-helpers.js', 'runtime/haskell/client.mjs', + 'runtime/haskell/worker.mjs', 'runtime/zig/site-session.mjs'} + assert {p.relative_to(args.site).as_posix() for p in args.site.rglob('*') + if p.suffix in ['.js', '.mjs']} == entries + graphs = json.loads((support / 'bundle-graphs.json').read_text()) + assert set(graphs) == entries | {'dist/probe-api.js'} + for entry, graph in graphs.items(): + assert set(graph['outputs']) == {'bundled/' + entry} + assert not graph['outputs']['bundled/' + entry]['imports'] + assert not any('/conformance/' in p for p in graph['inputs']) + file = support / 'probe-api.js' if entry == 'dist/probe-api.js' else args.site / entry + assert file.stat().st_size == graph['outputs']['bundled/' + entry]['bytes'] + assert 'runtime/haskell/shim/index.js' in graphs['runtime/haskell/worker.mjs']['inputs'] + assert not any('runtime/' in p for p in graphs['dist/app.js']['inputs']) + assert not (args.site / 'dist/probe-api.js').exists() + # Preserve the no-static-bundle-fallback control in each bundled download owner. + for script in ['dist/wasm-worker.js', 'dist/zig-helpers.js', 'runtime/haskell/worker.mjs', 'runtime/zig/site-session.mjs']: source = (args.site / script).read_text() assert '/_arboricx/bundle/hash/' in source and 'bundles/' not in source - assert 'bundles/' not in (args.site / 'dist/haskell-session.js').read_text() - assert (args.site / 'dist/local-module-worker.js').is_file() - assert (args.site / 'dist/local-modules-ui.js').is_file() - for backend, binary, scripts in [ - ('haskell', 'reactor.wasm', ['client.mjs', 'worker.mjs', 'bridge.mjs', 'pins.mjs']), - ('zig', 'arboricx.wasm', ['runtime.mjs', 'site-session.mjs']), - ]: - runtime = args.site / 'runtime' / backend - assert (runtime / binary).read_bytes()[:8] == b'\x00asm\x01\x00\x00\x00' - assert all((runtime / name).is_file() for name in scripts) - for name in ['shim/index.js', 'data/compiler.dag', 'data/environment.tsv', - 'data/compiler-exports.tsv', 'site/bundle.js', 'site/module-stage.js', - 'site/module-resolver.js', 'site/module-identity.js', 'site/local-modules.js', - 'site/module-bundle.js']: - assert (args.site / 'runtime/haskell' / name).is_file() + for backend, binary in [('haskell', 'reactor.wasm'), ('zig', 'arboricx.wasm')]: + assert (args.site / 'runtime' / backend / binary).read_bytes()[:8] == b'\x00asm\x01\x00\x00\x00' runtime = args.site / 'runtime/haskell' - pins = json.loads((runtime / 'pins.mjs').read_text().split('export default ', 1)[1].split(';', 1)[0]) + pins = json.loads((support / 'haskell-pins.mjs').read_text().split('export default ', 1)[1].split(';', 1)[0]) for name in ['compiler.dag', 'environment.tsv', 'compiler-exports.tsv']: assert hashlib.sha256((runtime / 'data' / name).read_bytes()).hexdigest() == pins[name] dag = (runtime / 'data/compiler.dag').read_bytes() diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -36,7 +36,7 @@ test('inspector supports mobile modality, escape, focus return and focus contain 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()=>{ +test('ecosystem remains with copyright and footer feedback',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>/); @@ -62,3 +62,14 @@ test('evaluation actions follow their expression and precede output, outside the assert.ok(html.indexOf('class="output-panel"')<html.indexOf('id="settings-panel"')); assert.match(html,/id="stop-run"/); }); +test('hero invitation, direct footer feedback and self-hosted preview metadata',async()=>{ + const html=await read('index.html'); + assert.match(html,/<title>Arboricx: tricu and portable tree programs<\/title>/); + assert.match(html,/Write familiar-looking code and explore how it becomes a tree you can run, inspect, and share\./); + assert.doesNotMatch(html,/Start Here walks through/); + assert.match(html,/<footer[^>]*>.*href="mailto:james@eversole.co">Email feedback<\/a>.*<\/footer>/); + assert.match(html,/<link rel="canonical" href="https:\/\/arbori.cx\/">/); + assert.match(html,/<meta property="og:image" content="https:\/\/arbori.cx\/images\/preview.png">/); + for(const path of ['images/favicon.png','images/icon.svg','images/apple-touch-icon.png','images/preview.png'])await access(new URL(path,here)); + assert.doesNotMatch(html,/twitter:|facebook\.com|linkedin\.com|contact-form|Privacy policy/); +}); diff --git a/ext/demo-site/webkit.nix b/ext/demo-site/webkit.nix @@ -0,0 +1,17 @@ +{ pkgs }: +let + browsers = pkgs.linkFarm "arboricx-webkit" (pkgs.lib.mapAttrsToList + (name: path: { inherit name path; }) + (pkgs.lib.filterAttrs (name: _: pkgs.lib.hasPrefix "webkit-" name) + pkgs.playwright-driver.browsers.entries)); +in pkgs.writeShellApplication { + name = "arboricx-webkit-check"; + runtimeInputs = [ pkgs.nodejs ]; + text = '' + export PLAYWRIGHT_MODULE=${pkgs.playwright-driver}/index.mjs + export PLAYWRIGHT_BROWSERS_PATH=${browsers} + # Nix supplies the patched browser libraries. + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1 + exec node ext/demo-site/test/backend-webkit.mjs "$@" + ''; +} diff --git a/ext/wasm/site-session.mjs b/ext/wasm/site-session.mjs @@ -2,8 +2,7 @@ // native owned handles stay here. Shared helpers validate/authenticate inputs. 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,expressions,treeResults,modules,moduleBundle]=await Promise.all(['bundle','codecs','compiler','contracts','packages','expression','tree-result','module-stage','module-bundle'].map(load)); + const {bundle,codec,comp,contracts,packages,expressions,treeResults,modules,moduleBundle}=await import(new URL('dist/zig-helpers.js',base).href); 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. diff --git a/flake.nix b/flake.nix @@ -458,6 +458,8 @@ -i${./src} -outputdir "$TMPDIR/objects" ${./test/InspectorOracle.hs} \ -o $out/bin/inspector-oracle ''; + packages.arboricxArtwork = import ./ext/demo-site/artwork.nix { inherit pkgs; }; + packages.arboricxWebkitCheck = import ./ext/demo-site/webkit.nix { inherit pkgs; }; packages.arboricxSite = siteRelease.arboricxSite; packages.arboricxSiteDeps = siteRelease.arboricxSiteDeps; packages.tricu-static = tricuMuslStatic;