commit c79b583e98a6aeb64a44608fae4010533e3433e3
parent f5dbd7942fadf6f1dde30b3b8735db1cc30d584a
Author: James Eversole <james@eversole.co>
Date: Tue, 13 Oct 2026 07:05:37 -0500
Simplify playground and exporter interfaces
Unify examples, named package versions, and direct bundle-hash loading in a shared Program selector. Distinguish pending choices from verified bundles, invalidate prior executables and exports on replacement, and keep provenance in Bundle details.
Add data-only tricu literal argument fields with optional raw typed JSON. Move resource guards and secondary controls into disclosures, default browser execution to guards off, and retain compilation consent and independent safety limits. Balance mobile tabs and update supported usage.
Validated with 69 focused tests, generated JavaScript parity, and the Chromium integration suite covering selection routes, keyboard hash submission, exact versions, recovery, exports, guards, and responsive layouts.
Diffstat:
14 files changed, 928 insertions(+), 218 deletions(-)
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md
@@ -65,8 +65,19 @@ seeding, or browser package selection.
### Published packages and local drafts
The package selector reads `packages.json`, a versioned static index of exact
-name/version → native manifest + whole-bundle pins. Demo buttons are shortcuts;
-the raw hash input still accepts arbitrary browser-compatible bundles. Selecting a
+name/version → native manifest + whole-bundle pins. A single **Program** selector
+is shared by Playground and Export, with grouped **Examples**, **Published
+packages**, and a visible **Load by bundle hash…** option. Examples load their
+pinned bundle and suggested inputs. Published packages 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** contains verification and named-release metadata, not loaders.
+
+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. A visible status distinguishes
+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.
@@ -82,10 +93,10 @@ The first compilation in a page session requires confirmation: the compiler is
itself tricu running as Tree Calculus in the browser, not JavaScript compilation.
It can take minutes and substantial memory. An elapsed indicator and Stop remain
available; source is retained after cancellation or failure. There is no default
-compilation work/time cutoff. Standard retains application memory-related guards;
-Use browser limits explicitly bypasses execution ceilings, with a warning that
-the browser may crash or reload the tab. Advanced controls are optional, not a prerequisite
-for running the published demos.
+compilation work/time cutoff or application memory/frame ceiling in the default
+browser-limits profile. Compilation consent still warns that the tab may crash
+or reload. Optional Standard guards can be enabled under **Advanced → Resource
+guards**; these controls are not a prerequisite for running published demos.
**Download published .arboricx** returns the original, bounded, hash-verified bundle
bytes, not a re-encoding, applied result or local draft. Textual exports likewise
@@ -97,9 +108,10 @@ separate recovery action: it downloads your authored text, not executable trees.
Draft edits are saved to one `localStorage` recovery slot on this origin, and
saved again synchronously before constructing the compiler Worker. Source,
selected export, arguments, output mode and curated demo context are retained;
-compiled heaps and risky resource settings are not. The latest draft replaces
-that slot. **Recover saved draft** restores an editor in Standard without running
-or compiling anything. Curated recovery requires the same published demo hash
+compiled heaps and resource settings are not. Recovery stores the last valid
+typed arguments; incomplete/invalid literal field text remains only in the current page. The latest draft replaces
+that slot. **Recover saved draft** restores an editor with the page default (application
+guards off), without running or compiling anything. Curated recovery requires the same published demo hash
and environment identifier still in the catalogue; missing context is an explicit
error, not a fallback to another environment. Saved source can still be downloaded.
**Forget saved draft** removes the recovery copy without clearing the editor.
@@ -113,12 +125,30 @@ or replace it. Keep important programs elsewhere too. Nothing is uploaded.
### Arguments, results, and IO
-Choose an export, ordered JSON **typed literal** arguments, an explicit result
-interpretation, and pure or browser-IO mode. Export metadata does not declare
-arity/types; no signature inference or hidden source evaluation occurs. Literal
-kinds: string, natural decimal string, Boolean, bytes, recursively typed list,
-and exact ternary tree. The latter may itself be a function tree. Partial and
-multiple applications are ordinary left-folded Tree Calculus application.
+Choose an export and enter ordered arguments 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. Result interpretation remains
+explicit; suggested pure/browser-IO mode can 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
+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,
+or source programs, and does not download the compiler. Boolean trees can be
+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.
+
+**Input help and raw JSON** 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
+valid values; invalid literal fields must be corrected before switching to raw.
+Partial and multiple applications are ordinary left-folded Tree Calculus application.
+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,
@@ -151,8 +181,10 @@ Tree graph remains available for inspecting the actual representation.
## Export stored definitions
-In **Export Definition**, enter a whole-file bundle SHA-256 and Load package (or
-choose a curated package), select its definition and tree format, then export.
+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
@@ -235,13 +267,14 @@ programs retain the default contract environment described above.
Compiler input is limited to 16,384 Unicode scalars. Diagnostic labels are limited
to 512 scalars, and displayed definitions to 16 names of 128 scalars each. Input size alone does not bound compilation work or memory. Long compilation
-is permitted; a large live graph can still reach Standard's memory guard.
-Use browser limits removes that application ceiling but not engine/OS limits.
+is permitted. Browser limits (the default) has no application ceiling, but still
+has engine/OS limits. Opting into Standard can stop a large live graph at its
+memory guard.
One compilation supports repeated
applications in the same Worker; each application resets its work allowance to
the applied limit. Compilation has its own allowance; allocation/work/GC counters
-remain cumulative. Live-node/frame limits stay enforced. Stop, profile changes, reset, source editing,
+remain cumulative. Live-node/frame limits are enforced only when guards are enabled. Stop, profile changes, reset, source editing,
recompilation, or replacing the session with an ordinary package discards that
heap. A compiled idle heap persists until discarded; Stop is available while it
is idle too. Invalid literal JSON can be corrected without recompiling. Successful
@@ -269,19 +302,22 @@ Switching panels stops in-flight package work, retaining selections and complete
exports; errors appear in the export panel rather than a hidden execution panel.
Compiler downloading/execution starts with Compile.
-The visible profile choice starts in **Standard**. Selecting **Use browser
-limits** explicitly bypasses node, evaluator-frame, work and active-time execution
-ceilings. The adjacent warning explains that the tab may crash or reload; no
-percentage of device RAM is reserved or guaranteed. Garbage collection and Stop
-remain available in both profiles. Browser-limit selection is not persisted across
-page reloads and is not recovered from draft storage.
+Both runners start in **Use browser limits**: application node, evaluator-frame,
+work and active-time execution ceilings are off. Under **Advanced → Resource
+guards**, uncheck **Disable application resource guards** to opt into Standard.
+The warning there and compilation consent explain that the tab may crash or
+reload; no percentage of device RAM is reserved or guaranteed. Garbage collection
+and Stop remain available in both profiles. Profile choices are not persisted
+across page reloads or recovered from draft storage; reload/recovery use the
+new guards-off default. Download, format, literal, IO, and export safeguards
+are independent and remain enabled.
The **Advanced** editors customize Standard's live nodes, work units, evaluator
frames and active seconds. Zero work or seconds means no corresponding cutoff;
node and frame settings remain positive and finite. Values are validated again
at the Worker boundary, even when the profile bypasses them. **Apply limits**
starts a fresh session; invalid edits preserve the current heap. **Restore
-defaults** restores Standard and its defaults. Browser limits disables these
+defaults** restores the guards-off page default and the predefined Standard values. Browser limits disables these
editors but remembers the last applied Standard settings for switching back.
Profiles/settings are not supplied by selection links or package metadata.
@@ -302,7 +338,8 @@ and ordinary-package roles must satisfy the smaller download cap.
- Ordinary package downloads: 1 MiB (compiler-only profile: 2 MiB); bundles: 100,000 physical indexed nodes, 4,096 exports/roots.
- Graph: 250,000 live nodes (including thunks), 20,000,000 work units,
- 50,000 evaluation frames by default, editable under Advanced. Loading/observers/codecs consume the session budget.
+ 50,000 evaluation frames when opting into Standard, editable under Advanced.
+ These application execution guards are off by default. Loading/observers/codecs consume the session budget.
- Inputs: at most 16 arguments; list nesting 16 /4,096 items; argument JSON
16,000 characters; ternary literal 4,096 nodes; text 4,096 scalars. Natural
inputs are bounded decimal strings; decoded naturals are capped at 4,096 bits.
@@ -310,7 +347,8 @@ and ordinary-package roles must satisfy the smaller download cap.
8-node previews. Strings/bytes have 4,096-element limits.
- IO: 128 actions; 256 scalars per line, no CR/LF; 16,384 output scalars.
- Main-page watchdog: 10 seconds active time per load; run deadlines use applied
- active seconds (default 10), with zero/disabled meaning unlimited run time. User step
+ active seconds (Standard preset 10), with zero/disabled meaning unlimited run time.
+ Runs have no active-time cutoff in the default guards-off profile. User step
and input pauses do not consume it. Timers can be delayed by browser throttling
or suspension; this is not a hard real-time guarantee. Stop/reset/replacement
terminates the Worker and does not execute cleanup/finalizers.
diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js
@@ -1,3 +1,4 @@
+import { argumentEditor } from './arguments-ui.js';
import { createTreeExport } from './export-app.js';
import { activePane } from './tabs.js';
import { DRAFT_KEY, readDraft, writeDraft } from './draft-storage.js';
@@ -26,8 +27,12 @@ function stopCompilerActivity() { clearInterval(compilerTimer); compilerTimer =
el('confirm-compile').onclick = () => { compilerAcknowledged = true; consent.close(); compileDraft(requestedRun); };
el('cancel-compile').onclick = () => consent.close();
let releases = [];
+const programPicker = el('program-picker');
+const versionPicker = el('package-version');
+let versionPackage;
const hashInput = el('bundle-hash'), exportsInput = el('export');
const argsInput = el('arguments'), resultCodec = el('output-type');
+const argumentsUI = argumentEditor(argsInput);
const mode = el('mode'), steps = el('step-mode');
const runButton = el('run'), stopButton = el('stop'), nextButton = el('next');
let settings = readSettings('package');
@@ -59,11 +64,19 @@ function clock() { deadline.start(); }
function runClock() { deadline.reset(activeMilliseconds(settings)); clock(); }
function controls(working) {
busy = working;
+ programPicker.disabled = busy || entries.length === 0;
+ versionPicker.disabled = busy;
+ hashInput.disabled = busy;
+ el('load-release').disabled = busy || !programPicker.value.startsWith('package:');
+ el('load').disabled = busy;
+ programSummary();
recoveryButtons();
runButton.disabled = busy || (draft ? !sourceEditor.value.trim() : !loaded);
runButton.textContent = draft ? (compiled ? 'Run local draft' : 'Compile & run draft') : 'Run published';
runButton.classList.toggle('draft', draft);
compileButton.disabled = busy || !draft;
+ compileButton.hidden = !draft;
+ restoreButton.hidden = !draft;
sourceEditor.disabled = busy;
restoreButton.disabled = busy || originalSource === undefined || !draft;
downloadButton.disabled = busy || draft || !loaded;
@@ -81,6 +94,7 @@ function controls(working) {
stopButton.disabled = !busy && !(draft && worker);
exportsInput.disabled = busy || (draft ? !compiled : !loaded);
argsInput.disabled = busy;
+ argumentsUI.disable(busy);
mode.disabled = busy;
resultCodec.disabled = busy;
steps.disabled = busy;
@@ -128,6 +142,7 @@ function finish(message, error = false) {
function setPreset(entry, name = entry.export) {
const preset = entry.presets?.[name] ?? entry;
argsInput.value = JSON.stringify(preset.arguments, null, 2);
+ argumentsUI.refresh(true);
resultCodec.value = preset.output;
mode.value = preset.mode;
}
@@ -302,7 +317,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) {
} };
send({ type: 'load', ...selection, ...settings });
}
-function select(entry) {
+function select(entry, release) {
draft = false;
scratch = false;
originalSource = undefined;
@@ -316,15 +331,14 @@ function select(entry) {
hashInput.value = entry.hash;
sourceEditor.hidden = true;
el('source-notes').hidden = false;
- const currentVersion = el('package-version').value;
- showRelease(releases.find(r => r.hash === entry.hash && r.demo === entry.id && r.version === currentVersion) ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id));
+ const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id);
+ chooseProgram(release ? `package:${release.name}` : `demo:${entry.id}`, release?.version);
+ showRelease(pinned);
setPreset(entry);
el('source').textContent = 'Verifying package…';
- for (const button of document.querySelectorAll('[data-demo]'))
- button.setAttribute('aria-pressed', String(button.dataset['demo'] === entry.id));
newWorker(false);
}
-el('load').onclick = () => {
+function invalidateProgram() {
draft = false;
scratch = false;
originalSource = undefined;
@@ -333,26 +347,41 @@ el('load').onclick = () => {
clearOutput();
clearTreeExport();
loaded = false;
+ selection = undefined;
+ 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.';
+ el('package-info').textContent = 'No bundle loaded.';
+ showRelease();
+ controls(false);
+ el('status').textContent = 'Load package';
+}
+function loadBundle(release) {
+ const value = release?.hash ?? hashInput.value;
+ invalidateProgram();
+ chooseProgram(release ? `package:${release.name}` : 'hash', release?.version);
+ hashInput.value = value;
try {
- sourceEditor.hidden = true;
- el('source-notes').hidden = false;
- showRelease();
- const hash = bundleHash(hashInput.value);
- selected = undefined;
+ const hash = bundleHash(value);
selection = { hash };
- for (const button of document.querySelectorAll('[data-demo]'))
- button.setAttribute('aria-pressed', 'false');
argsInput.value = '[]';
+ argumentsUI.refresh(true);
resultCodec.value = 'tree';
mode.value = 'pure';
+ showRelease(release);
el('source').textContent = 'Verifying registry package…';
newWorker(false);
}
catch (error) {
finish(error instanceof Error ? error.message : String(error), true);
}
-};
-hashInput.oninput = () => { showRelease(); draft = false; scratch = false; originalSource = undefined; sourceEditor.hidden = true; el('source-notes').hidden = false; el('package-info').textContent = 'Load this package to verify its hash and choose a definition.'; clearTreeExport(); dispose(); loaded = false; selection = undefined; selected = undefined; controls(false); el('status').textContent = 'Load package'; };
+}
+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); };
runButton.onclick = () => {
@@ -367,9 +396,7 @@ runButton.onclick = () => {
return;
clearOutput();
try {
- if (argsInput.value.length > 16_000)
- throw new Error('Argument JSON limit exceeded');
- const args = literals(JSON.parse(argsInput.value)), output = outputType(resultCodec.value);
+ 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 };
if (worker) {
controls(true);
@@ -430,52 +457,95 @@ shareButton.onclick = async () => {
}
};
window.addEventListener('pagehide', dispose);
-function populatePackages() {
- const packages = el('package-name');
- packages.replaceChildren();
+function chooseProgram(value, version) {
+ programPicker.value = value;
+ const name = value.startsWith('package:') ? value.slice(8) : undefined;
+ el('program-release').hidden = name === undefined;
+ el('program-hash').hidden = value !== 'hash';
+ if (name && versionPackage !== name) {
+ versionPackage = name;
+ versionPicker.replaceChildren();
+ for (const release of releases.filter(r => r.name === name)) {
+ const option = document.createElement('option');
+ option.value = release.version;
+ option.textContent = release.version;
+ versionPicker.append(option);
+ }
+ }
+ if (version !== undefined)
+ versionPicker.value = version;
+ programSummary();
+}
+function programSummary() {
+ const label = programPicker.selectedOptions[0]?.textContent ?? 'Program';
+ const name = programPicker.value.startsWith('package:') ? `${label}@${versionPicker.value}`
+ : programPicker.value === 'hash' ? `bundle ${selection?.hash ?? (hashInput.value || '(enter a hash)')}` : label;
+ el('program-selection-status').textContent = draft ? (scratch ? 'Local scratch draft. Not a published bundle.' : `Local draft based on ${name}. Published exports are unavailable until restored.`)
+ : loaded && selection ? `Loaded: ${name}. Loading does not run it.`
+ : busy && selection ? `Loading: ${name}. Verifying bundle bytes…`
+ : programPicker.value === 'hash' ? 'Enter a bundle hash, then Load bundle. No program is loaded.'
+ : programPicker.value.startsWith('package:') ? `Choose a version of ${label}, then Load package. No program is loaded.`
+ : 'Choose a program to load. Loading does not run it.';
+}
+function populatePrograms() {
+ programPicker.replaceChildren();
const placeholder = document.createElement('option');
placeholder.value = '';
- placeholder.textContent = 'Choose a package';
- packages.append(placeholder);
+ placeholder.disabled = true;
+ placeholder.textContent = 'Choose a program';
+ programPicker.append(placeholder);
+ const demos = document.createElement('optgroup');
+ demos.label = 'Examples';
+ for (const entry of entries) {
+ const option = document.createElement('option');
+ option.value = `demo:${entry.id}`;
+ option.textContent = entry.title;
+ demos.append(option);
+ }
+ const packages = document.createElement('optgroup');
+ packages.label = 'Published packages';
for (const name of new Set(releases.map(r => r.name))) {
const option = document.createElement('option');
- option.value = name;
+ option.value = `package:${name}`;
option.textContent = name;
packages.append(option);
}
- packages.onchange = () => {
- const versions = el('package-version');
- versions.replaceChildren();
- el('load-release').disabled = !packages.value;
- for (const release of releases.filter(r => r.name === packages.value)) {
- const option = document.createElement('option');
- option.value = release.version;
- option.textContent = release.version;
- versions.append(option);
- }
- };
- packages.dispatchEvent(new Event('change'));
+ const hash = document.createElement('option');
+ hash.value = 'hash';
+ hash.textContent = 'Load by bundle hash…';
+ programPicker.append(demos, packages, hash);
+ programPicker.disabled = false;
}
function showRelease(release) {
- const packages = el('package-name');
- packages.value = release?.name ?? '';
- packages.dispatchEvent(new Event('change'));
- el('package-version').value = release?.version ?? '';
- el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'Direct bundle pin (no name/version selected).';
+ el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'No named release selected. Direct bundle hashes pin the whole file.';
}
+programPicker.onchange = () => {
+ const value = programPicker.value;
+ if (value.startsWith('demo:')) {
+ const entry = entries.find(e => e.id === value.slice(5));
+ if (entry)
+ select(entry);
+ }
+ else {
+ chooseProgram(value);
+ if (value === 'hash')
+ hashInput.value = '';
+ invalidateProgram();
+ if (value === 'hash')
+ hashInput.focus();
+ }
+};
+versionPicker.onchange = invalidateProgram;
el('load-release').onclick = () => {
- const name = el('package-name').value, version = el('package-version').value;
- const release = releases.find(r => r.name === name && r.version === version);
+ const name = programPicker.value.startsWith('package:') ? programPicker.value.slice(8) : undefined;
+ const release = releases.find(r => r.name === name && r.version === versionPicker.value);
if (!release)
return;
const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
if (demo)
- select(demo);
- else {
- hashInput.value = release.hash;
- el('load').click();
- }
- showRelease(release);
+ select(demo, release);
+ else
+ loadBundle(release);
};
downloadButton.onclick = () => {
if (draft || !loaded || !selection)
@@ -536,6 +606,7 @@ el('recover-draft').onclick = () => {
dispose();
clearOutput();
clearTreeExport();
+ chooseProgram(entry ? `demo:${entry.id}` : '');
draft = true;
scratch = !saved.demo;
selected = entry;
@@ -551,11 +622,12 @@ el('recover-draft').onclick = () => {
preferredName = saved.name;
exportsInput.replaceChildren();
argsInput.value = saved.arguments;
+ argumentsUI.refresh(true);
resultCodec.value = output;
mode.value = saved.mode;
- // A recovered draft does not inherit a previously selected risky profile.
+ // Recovery uses the page default, never stored resource settings.
const profile = el('compiler-uncapped');
- profile.checked = false;
+ profile.checked = true;
profile.dispatchEvent(new Event('change'));
el('source-label').textContent = entry ? `Recovered draft · ${entry.title}` : 'Recovered scratch program · contract helpers';
controls(false);
@@ -729,9 +801,7 @@ function runDraft() {
if (!compiled || !worker)
return;
try {
- if (argsInput.value.length > 16_000)
- throw new Error('Argument JSON limit exceeded');
- const args = literals(JSON.parse(argsInput.value)), output = outputType(resultCodec.value);
+ const args = argumentsUI.read(), output = outputType(resultCodec.value);
clearOutput();
controls(true);
runClock();
@@ -755,6 +825,7 @@ for (const button of document.querySelectorAll('[data-compiler-example]'))
const example = examples[button.dataset['compilerExample']];
if (!example)
return;
+ chooseProgram('');
dispose();
clearOutput();
clearTreeExport();
@@ -767,6 +838,7 @@ for (const button of document.querySelectorAll('[data-compiler-example]'))
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;
saveDraft();
@@ -784,6 +856,7 @@ function loadFragment() {
if (local)
select(local);
else {
+ chooseProgram('hash');
showRelease();
draft = false;
scratch = false;
@@ -794,6 +867,7 @@ function loadFragment() {
loaded = false;
selected = undefined;
argsInput.value = '[]';
+ argumentsUI.refresh(true);
resultCodec.value = 'tree';
mode.value = 'pure';
selection = { hash };
@@ -845,17 +919,8 @@ 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)));
- populatePackages();
+ populatePrograms();
recoveryButtons();
- for (const entry of entries) {
- const button = document.createElement('button');
- button.type = 'button';
- button.dataset['demo'] = entry.id;
- button.textContent = entry.title;
- button.setAttribute('aria-pressed', 'false');
- button.onclick = () => select(entry);
- el('demo-picker').append(button);
- }
if (!packagePane() || selection || draft)
return;
if (!loadFragment()) {
@@ -886,13 +951,6 @@ window.addEventListener('pane-change', () => {
document.querySelector(`#${activePane()} [data-package-slot]`).append(el('package-selection'));
if (!initialized)
void initializePackages();
- else if (!draft && entries.length && !selection) {
- if (!loadFragment()) {
- const initial = entries.find(e => e.id === 'intro') ?? entries[0];
- if (initial)
- select(initial);
- }
- }
else if (!draft && entries.length && !loaded && selection)
newWorker(false);
}
diff --git a/ext/demo-site/dist/arguments-ui.js b/ext/demo-site/dist/arguments-ui.js
@@ -0,0 +1,107 @@
+import { literals } from './codecs.js';
+import { parseLiteral, printLiteral } from './literal-input.js';
+export function argumentEditor(raw) {
+ const container = document.getElementById('argument-fields');
+ const add = document.getElementById('add-argument');
+ const rawMode = document.getElementById('raw-arguments');
+ let snapshot = '', disabled = false;
+ let rows = [];
+ function render() {
+ container.replaceChildren();
+ if (!rows.length) {
+ const hint = document.createElement('p');
+ hint.className = 'hint';
+ hint.textContent = 'No arguments. This definition already contains its inputs.';
+ container.append(hint);
+ }
+ rows.forEach((row, i) => {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'argument-row';
+ const label = document.createElement('label');
+ label.htmlFor = `argument-${i}`;
+ label.textContent = `Argument ${i + 1}`;
+ row.input.id = label.htmlFor;
+ row.input.rows = 2;
+ row.input.maxLength = 16000;
+ row.input.spellcheck = false;
+ row.input.disabled = disabled;
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.textContent = 'Remove';
+ remove.disabled = disabled;
+ remove.setAttribute('aria-label', `Remove argument ${i + 1}`);
+ remove.onclick = () => { rows.splice(i, 1); render(); sync(); };
+ row.input.oninput = sync;
+ wrapper.append(label, row.input, remove);
+ container.append(wrapper);
+ });
+ container.hidden = rawMode.checked;
+ add.hidden = rawMode.checked;
+ add.disabled = disabled || rows.length >= 16;
+ raw.hidden = !rawMode.checked;
+ rawMode.disabled = disabled;
+ }
+ function values() {
+ return literals(rows.map(row => row.original && row.input.value === row.text ? row.original : parseLiteral(row.input.value)));
+ }
+ function sync() {
+ try {
+ raw.value = JSON.stringify(values());
+ snapshot = raw.value;
+ }
+ catch { /* Retain invalid field text for correction; read reports it. */ }
+ }
+ function refresh(reset = false) {
+ if (reset)
+ rawMode.checked = false;
+ if (!reset && raw.value === snapshot) {
+ render();
+ return;
+ }
+ try {
+ rows = literals(JSON.parse(raw.value)).map(original => {
+ const input = document.createElement('textarea'), text = printLiteral(original);
+ input.value = text;
+ return { input, original, text };
+ });
+ snapshot = raw.value;
+ }
+ catch {
+ rawMode.checked = true;
+ }
+ render();
+ }
+ add.onclick = () => { const input = document.createElement('textarea'); input.value = 't'; rows.push({ input, text: 't' }); render(); sync(); input.focus(); };
+ raw.oninput = () => { snapshot = ''; };
+ rawMode.onchange = () => {
+ if (!rawMode.checked)
+ refresh();
+ else {
+ try {
+ raw.value = JSON.stringify(values());
+ snapshot = raw.value;
+ }
+ catch (error) {
+ rawMode.checked = false;
+ document.getElementById('result').textContent = `Correct the argument before switching editors: ${error instanceof Error ? error.message : String(error)}`;
+ }
+ }
+ render();
+ };
+ refresh();
+ return {
+ refresh,
+ disable(value) { disabled = value; refresh(); },
+ read() {
+ if (raw.value.length > 16000)
+ throw new Error('Argument text limit exceeded');
+ if (rawMode.checked || raw.value !== snapshot)
+ return literals(JSON.parse(raw.value));
+ const result = values();
+ if (JSON.stringify(result).length > 16000)
+ throw new Error('Argument text limit exceeded');
+ sync();
+ return result;
+ },
+ };
+}
diff --git a/ext/demo-site/dist/limits-ui.js b/ext/demo-site/dist/limits-ui.js
@@ -45,11 +45,11 @@ for (const runner of ['package', 'compiler']) {
actions.append(apply, restore);
form.append(actions, error);
container.replaceWith(form);
- committed.set(runner, executionSettings(defaults, runner));
+ committed.set(runner, executionSettings(defaults, runner, true));
function update() {
const settings = readSettings(runner);
const customized = Object.entries(defaults).some(([key, value]) => settings.limits[key] !== value);
- summary.textContent = `Advanced: ${settings.uncapped ? 'browser limits; execution guards off' : customized ? 'custom resource guards' : 'resource guards and diagnostics'}`;
+ summary.textContent = `Resource guards${settings.uncapped ? ' (off)' : customized ? ' (custom)' : ' (on)'}`;
for (const input of fields.values())
input.disabled = settings.uncapped;
apply.disabled = settings.uncapped;
@@ -88,8 +88,8 @@ for (const runner of ['package', 'compiler']) {
restore.onclick = () => {
for (const [key, input] of fields)
input.value = String(defaults[key]);
- checkbox.checked = false;
- publish(executionSettings(defaults, runner));
+ checkbox.checked = true;
+ publish(executionSettings(defaults, runner, true));
};
update();
}
diff --git a/ext/demo-site/dist/literal-input.js b/ext/demo-site/dist/literal-input.js
@@ -0,0 +1,137 @@
+import { literals } from './codecs.js';
+// Deliberately a data-only subset, not a second source compiler. No names,
+// arbitrary applications, comments, or effects are evaluated in argument fields.
+export function parseLiteral(source) {
+ if (source.length > 16_000)
+ throw new Error('Argument text limit exceeded');
+ let at = 0, items = 0;
+ const space = () => { while (source[at] === ' ' || source[at] === '\n')
+ at++; };
+ const fail = () => { throw new Error(`Expected a literal at character ${at + 1}`); };
+ function atom(depth) {
+ if (++items > 4096 || depth > 16)
+ throw new Error('Literal nesting/item limit exceeded');
+ space();
+ const start = at, token = source[at++];
+ if (token === '"') {
+ let value = '';
+ while (at < source.length) {
+ const char = source[at++];
+ if (char === '"') {
+ const scalars = Array.from(value);
+ if (scalars.length > 4096 || scalars.some(c => { const n = c.codePointAt(0); return n >= 0xd800 && n <= 0xdfff; }))
+ throw new Error('Expected bounded Unicode scalar text');
+ return { type: 'string', value };
+ }
+ if (char === '\\') {
+ const escapes = { n: '\n', t: '\t', r: '\r', f: '\f', b: '\b', '\\': '\\', '"': '"', "'": "'" };
+ const escaped = escapes[source[at++]];
+ if (escaped === undefined)
+ fail();
+ value += escaped;
+ }
+ else
+ value += char;
+ }
+ fail();
+ }
+ if (token === '[') {
+ const value = [];
+ space();
+ while (source[at] !== ']') {
+ if (at >= source.length)
+ fail();
+ value.push(atom(depth + 1));
+ space();
+ }
+ at++;
+ return { type: 'list', value };
+ }
+ if (token === '(') {
+ const value = expression(depth + 1);
+ space();
+ if (source[at++] !== ')')
+ fail();
+ return value;
+ }
+ if (token === 't')
+ return { type: 'tree', value: '0' };
+ if (token && /[0-9]/u.test(token)) {
+ while (source[at] && /[0-9]/u.test(source[at]))
+ at++;
+ return literals([{ type: 'number', value: source.slice(start, at).replace(/^0+(?=\d)/u, '') }])[0];
+ }
+ return fail();
+ }
+ function expression(depth) {
+ space();
+ const constructor = source[at] === 't';
+ const first = atom(depth);
+ if (!constructor)
+ return first;
+ const children = [];
+ space();
+ while (at < source.length && source[at] !== ')' && source[at] !== ']') {
+ if (children.length === 2)
+ fail();
+ children.push(atom(depth + 1));
+ space();
+ }
+ // Constructor children can contain encoded scalar/list data without evaluation.
+ const ternary = (literal) => {
+ switch (literal.type) {
+ case 'tree': return literal.value;
+ case 'bool': return literal.value ? '10' : '0';
+ case 'number': {
+ let n = BigInt(literal.value), result = '';
+ while (n) {
+ result += n & 1n ? '210' : '20';
+ n >>= 1n;
+ }
+ return result + '0';
+ }
+ case 'string': return ternary({ type: 'list', value: Array.from(literal.value).map(c => ({ type: 'number', value: String(c.codePointAt(0)) })) });
+ case 'bytes': return ternary({ type: 'list', value: literal.value.map(n => ({ type: 'number', value: String(n) })) });
+ case 'list': return literal.value.map(v => '2' + ternary(v)).join('') + '0';
+ }
+ };
+ const value = String(children.length) + children.map(ternary).join('');
+ if (value.length > 8192)
+ throw new Error('Tree literal limit exceeded');
+ return { type: 'tree', value };
+ }
+ const value = expression(0);
+ space();
+ if (at !== source.length)
+ fail();
+ return literals([value])[0];
+}
+export function printLiteral(literal) {
+ switch (literal.type) {
+ case 'number': return literal.value;
+ case 'string': return '"' + literal.value.replace(/[\\"\n\t\r\f\b]/gu, c => ({ '\\': '\\\\', '"': '\\"', '\n': '\\n', '\t': '\\t', '\r': '\\r', '\f': '\\f', '\b': '\\b' })[c]) + '"';
+ case 'bool': return literal.value ? '(t t)' : 't';
+ case 'bytes': return '[' + literal.value.join(' ') + ']';
+ case 'list': return '[' + literal.value.map(printLiteral).join(' ') + ']';
+ case 'tree': {
+ let at = 0;
+ const value = literal.value;
+ function read(depth) {
+ if (depth > 16)
+ throw new Error('Use raw JSON for deeply nested trees');
+ const tag = value[at++];
+ if (tag === '0')
+ return 't';
+ if (tag === '1')
+ return `(t ${read(depth + 1)})`;
+ if (tag === '2')
+ return `(t ${read(depth + 1)} ${read(depth + 1)})`;
+ throw new Error('Invalid ternary tree');
+ }
+ const result = read(0);
+ if (at !== literal.value.length)
+ throw new Error('Trailing ternary data');
+ return result;
+ }
+ }
+}
diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html
@@ -24,33 +24,25 @@
<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 Definition</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><span class="badge">Inspect · apply · interact</span></div>
- <p class="muted">Count a tree's nodes, compare its structure, validate a color, or have a conversation. Load a published package to run, download or export its definitions. Curated demos include editable source and a pinned compilation environment. Editing creates a local draft, never a published release.</p>
- <div id="package-resource-choice" class="resource-choice">
- <p>Execution profile: <strong id="package-resource-mode">Standard</strong></p>
- <label><input id="uncapped" type="checkbox" aria-describedby="package-resource-warning"> Use browser limits</label>
- <p id="package-resource-warning" class="hint">Standard stops at application resource guards. Use browser limits allows larger programs, but may crash or reload the tab. Stop and garbage collection remain available; this does not reserve RAM.</p>
- </div>
- <details id="package-settings" class="execution-limits"><summary id="package-limits-summary">Advanced: resource guards and diagnostics</summary><div class="limits-content"><h3>Session settings</h3><div id="package-limits-defaults"></div><p class="hint">These optional controls customize Standard. They are bypassed in Use browser limits; download, format and IO safeguards are not. Garbage collection stays active in both profiles.</p><p class="hint">Package input: 1 MiB bundle and 100,000 stored nodes; loading allows 10 active seconds. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details>
- <div id="compiler-resource-choice" class="resource-choice" hidden>
- <p>Execution profile: <strong id="compiler-resource-mode">Standard</strong></p>
- <label><input id="compiler-uncapped" type="checkbox" aria-describedby="compiler-resource-warning"> Use browser limits</label>
- <p id="compiler-resource-warning" class="hint">Standard stops at application resource guards. Use browser limits allows larger programs, but may crash or reload the tab. Stop and garbage collection remain available; this does not reserve RAM.</p>
- </div>
- <details id="compiler-settings" class="execution-limits" hidden><summary id="compiler-limits-summary">Advanced: resource guards and diagnostics</summary><div class="limits-content"><h3>Session settings</h3><div id="compiler-limits-defaults"></div><p class="hint">These optional controls customize Standard. They are bypassed in Use browser limits; download, format and IO safeguards are not. Garbage collection stays active in both profiles.</p><p class="hint">Source: 16,384 characters and up to 16 definitions. IO uses the same text limits as the playground. Compile once, then run definitions with new arguments. Compilation and each run have separate work and active-time allowances. Standard enforces live-node and frame limits; counters remain cumulative.</p></div></details>
+ <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, change its arguments, and run it locally. Edit the source to make your own draft.</p>
+
+
<div data-package-slot><div id="package-selection">
- <div class="package-controls"><label for="package-name">Package index</label><div class="input-row"><select id="package-name" aria-label="Package"></select><select id="package-version" aria-label="Exact version"></select><button id="load-release" type="button">Load release</button></div><p id="release-info" class="hint">Named releases, pinned to exact bytes.</p></div>
- <div id="demo-picker" class="demo-picker" role="group" aria-label="Curated packages"></div>
- <div class="package-controls"><label for="bundle-hash">Bundle SHA-256 (whole file)</label><div class="input-row"><input id="bundle-hash" maxlength="64" spellcheck="false" autocomplete="off"><button id="load" type="button">Load package</button></div><p id="package-info" class="hint">Loading curated catalogue…</p></div>
+ <div class="program-choice"><label for="program-picker">Program</label><select id="program-picker" aria-describedby="program-selection-status" disabled><option value="">Loading programs…</option></select></div>
+ <div id="program-release" class="program-input" 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">Load package</button></div></div>
+ <form id="program-hash" class="program-input" 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">Load bundle</button></div><p id="bundle-hash-help" class="hint">Paste the 64-character hash of an Arboricx bundle to load it from this site's registry.</p></form>
+ <p id="program-selection-status" class="hint" role="status">Choose a program to load. Loading does not run it.</p>
+ <details class="bundle-details"><summary>Bundle details</summary><p id="release-info" class="hint">No named release selected.</p><p id="package-info" class="hint">Loading program catalogue…</p></details>
</div></div>
<div class="lab">
<section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Source / package notes</h3><span id="source-label">tricu programs</span></div>
<p id="compiler-disclosure" class="hint" hidden>The compiler is a tricu program: trees compiling source into trees, not JavaScript compilation. This can take minutes and use substantial memory. Stop at any time; your source stays here. Standard can stop large drafts at its memory guard. Use browser limits allows larger graphs but risks a tab crash or reload.</p><p id="compile-activity" class="hint" role="status" hidden></p>
- <div class="draft-recovery"><p id="draft-save-status" class="hint" role="status"></p><button type="button" id="recover-draft" hidden>Recover saved draft</button><button type="button" id="download-draft-source" hidden>Download saved source</button><button type="button" id="forget-draft" hidden>Forget saved draft</button></div>
+ <details class="draft-recovery"><summary>Draft recovery</summary><p id="draft-save-status" class="hint" role="status"></p><button type="button" id="recover-draft" hidden>Recover saved draft</button><button type="button" id="download-draft-source" hidden>Download saved source</button><button type="button" id="forget-draft" hidden>Forget saved draft</button></details>
<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>
@@ -63,32 +55,42 @@
<div class="panel-heading"><h3 id="execution-title">Apply a tree</h3><span id="status" role="status">Loading</span></div>
<div class="application-fields">
<label for="export">Export</label><select id="export" disabled></select>
- <label for="arguments">Arguments (JSON array)</label>
- <textarea id="arguments" rows="5" maxlength="16000" spellcheck="false">[]</textarea>
- <details><summary>Literal input examples</summary><pre>[{"type":"string","value":"hello"}]
-[{"type":"number","value":"42"}]
-[{"type":"bool","value":true}]
-[{"type":"bytes","value":[0,128,255]}]
-[{"type":"tree","value":"21100"}]
-[{"type":"list","value":[
- {"type":"number","value":"256"}]}]</pre><p class="hint">Use [] for its arguments when a definition that already contains its inputs. Otherwise, list arguments in application order. Ternary trees use 0 for a leaf, 1 followed by a child for a stem, and 2 followed by two children for a fork. Numbers are nonnegative decimal strings.</p></details>
- <div class="codec-row"><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><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>
+ <div class="argument-heading"><h3>Arguments</h3><button id="add-argument" type="button">Add argument</button></div>
+ <div id="argument-fields"></div>
+ <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>
<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><pre id="graph-result" hidden aria-label="Bounded result graph"></pre>
- <div class="controls"><button type="button" id="run" class="primary" disabled>Run published</button><button type="button" id="stop" disabled>Stop</button><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy published selection link</button><button type="button" id="download-bundle" disabled>Download published .arboricx</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>
+ <div class="controls"><button type="button" id="run" class="primary" disabled>Run published</button><button type="button" id="stop" disabled>Stop</button><button type="button" id="reset">Reset</button></div>
+ <details class="run-tools"><summary>Advanced</summary><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="share" disabled>Copy published selection link</button><button type="button" id="download-bundle" disabled>Download published .arboricx</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>
+ <details id="package-settings" class="execution-limits"><summary id="package-limits-summary">Advanced: resource guards and diagnostics</summary><div class="limits-content"><div id="package-resource-choice" class="resource-choice">
+ <p>Execution profile: <strong id="package-resource-mode">Use browser limits</strong></p>
+ <label><input id="uncapped" type="checkbox" checked aria-describedby="package-resource-warning"> Disable application resource guards</label>
+ <p id="package-resource-warning" class="hint">Standard stops at application resource guards. Use browser limits allows larger programs, but may crash or reload the tab. Stop and garbage collection remain available; this does not reserve RAM.</p>
+ </div><h3>Session settings</h3><div id="package-limits-defaults"></div><p class="hint">These optional controls customize Standard. They are bypassed in Use browser limits; download, format and IO safeguards are not. Garbage collection stays active in both profiles.</p><p class="hint">Package input: 1 MiB bundle and 100,000 stored nodes; loading allows 10 active seconds. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details>
+ <details id="compiler-settings" class="execution-limits" hidden><summary id="compiler-limits-summary">Advanced: resource guards and diagnostics</summary><div class="limits-content"><div id="compiler-resource-choice" class="resource-choice" hidden>
+ <p>Execution profile: <strong id="compiler-resource-mode">Use browser limits</strong></p>
+ <label><input id="compiler-uncapped" type="checkbox" checked aria-describedby="compiler-resource-warning"> Disable application resource guards</label>
+ <p id="compiler-resource-warning" class="hint">Standard stops at application resource guards. Use browser limits allows larger programs, but may crash or reload the tab. Stop and garbage collection remain available; this does not reserve RAM.</p>
+ </div><h3>Session settings</h3><div id="compiler-limits-defaults"></div><p class="hint">These optional controls customize Standard. They are bypassed in Use browser limits; download, format and IO safeguards are not. Garbage collection stays active in both profiles.</p><p class="hint">Source: 16,384 characters and up to 16 definitions. IO uses the same text limits as the playground. Compile once, then run definitions with new arguments. Compilation and each run have separate work and active-time allowances. Standard enforces live-node and frame limits; counters remain cumulative.</p></div></details>
+ </details>
<p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p>
</section>
</div>
- <section class="trace-panel" 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 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>
</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><span class="badge">Preview · copy · download</span></div>
- <p class="muted">Save a named tree from a verified Arboricx package. Nothing is run and no arguments are applied. Package and definition selection are shared with Playground. Exports and bundle downloads contain published trees, never local drafts; restore the published source first when editing.</p>
+ <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">
@@ -101,7 +103,7 @@
<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>
- <p class="hint">Expanded formats are limited to 2 MiB; DAG preserves sharing and allows up to 8 MiB. Each load or export allows 10 active seconds. These files contain tree structure, not an Arboricx bundle or the original program source.</p>
+ <details><summary>Export safeguards</summary><p class="hint">Expanded formats are limited to 2 MiB; DAG preserves sharing and allows up to 8 MiB. Each load or export allows 10 active seconds. 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>
diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts
@@ -1,3 +1,4 @@
+import { argumentEditor } from './arguments-ui.js';
import { createTreeExport } from './export-app.js';
import { activePane } from './tabs.js';
import { DRAFT_KEY, readDraft, writeDraft } from './draft-storage.js';
@@ -25,8 +26,12 @@ function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTi
el<HTMLButtonElement>('confirm-compile').onclick = () => { compilerAcknowledged = true; consent.close(); compileDraft(requestedRun); };
el<HTMLButtonElement>('cancel-compile').onclick = () => consent.close();
let releases: Release[] = [];
+const programPicker = el<HTMLSelectElement>('program-picker');
+const versionPicker = el<HTMLSelectElement>('package-version');
+let versionPackage: string | undefined;
const hashInput = el<HTMLInputElement>('bundle-hash'), exportsInput = el<HTMLSelectElement>('export');
const argsInput = el<HTMLTextAreaElement>('arguments'), resultCodec = el<HTMLSelectElement>('output-type');
+const argumentsUI = argumentEditor(argsInput);
const mode = el<HTMLSelectElement>('mode'), steps = el<HTMLInputElement>('step-mode');
const runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElement>('stop'), nextButton = el<HTMLButtonElement>('next');
let settings = readSettings('package');
@@ -54,11 +59,16 @@ function clock(): void { deadline.start(); }
function runClock(): void { deadline.reset(activeMilliseconds(settings)); clock(); }
function controls(working: boolean): void {
busy = working;
+ programPicker.disabled = busy || entries.length === 0;
+ versionPicker.disabled = busy; hashInput.disabled = busy;
+ el<HTMLButtonElement>('load-release').disabled = busy || !programPicker.value.startsWith('package:');
+ el<HTMLButtonElement>('load').disabled = busy;
+ programSummary();
recoveryButtons();
runButton.disabled = busy || (draft ? !sourceEditor.value.trim() : !loaded);
runButton.textContent = draft ? (compiled ? 'Run local draft' : 'Compile & run draft') : 'Run published';
runButton.classList.toggle('draft', draft);
- compileButton.disabled = busy || !draft;
+ compileButton.disabled = busy || !draft; compileButton.hidden = !draft; restoreButton.hidden = !draft;
sourceEditor.disabled = busy;
restoreButton.disabled = busy || originalSource === undefined || !draft;
downloadButton.disabled = busy || draft || !loaded;
@@ -73,7 +83,7 @@ function controls(working: boolean): void {
el('package-settings').hidden = draft;
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy;
stopButton.disabled = !busy && !(draft && worker);
- exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy;
+ 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);
@@ -98,7 +108,7 @@ function finish(message: string, error = false): void {
}
function setPreset(entry: CatalogueEntry, name = entry.export): void {
const preset = entry.presets?.[name] ?? entry;
- argsInput.value = JSON.stringify(preset.arguments, null, 2); resultCodec.value = preset.output; mode.value = preset.mode;
+ argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = preset.output; mode.value = preset.mode;
}
function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: (message: ClientMessage) => void): void {
treeExport.begin(); controls(true); deadline.reset(PACKAGE_ACTIVE_MS); clock();
@@ -190,37 +200,46 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa
current.onerror = event => { if (worker === current) { event.preventDefault(); if (!currentVerified) loaded = false; finish(event.message || 'Worker failed.', true); } };
send({ type: 'load', ...selection, ...settings });
}
-function select(entry: CatalogueEntry): void {
+function select(entry: CatalogueEntry, release?: Release): void {
draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false;
dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false;
selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash;
sourceEditor.hidden = true; el('source-notes').hidden = false;
- const currentVersion = el<HTMLSelectElement>('package-version').value;
- showRelease(releases.find(r => r.hash === entry.hash && r.demo === entry.id && r.version === currentVersion) ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id));
+ const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id);
+ chooseProgram(release ? `package:${release.name}` : `demo:${entry.id}`, release?.version);
+ showRelease(pinned);
setPreset(entry); el('source').textContent = 'Verifying package…';
- for (const button of document.querySelectorAll<HTMLButtonElement>('[data-demo]')) button.setAttribute('aria-pressed', String(button.dataset['demo'] === entry.id));
newWorker(false);
}
-el<HTMLButtonElement>('load').onclick = () => {
+function invalidateProgram(): void {
draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false;
- dispose(); clearOutput(); clearTreeExport(); loaded = false;
+ dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; 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.';
+ el('package-info').textContent = 'No bundle loaded.'; showRelease();
+ controls(false); el('status').textContent = 'Load package';
+}
+function loadBundle(release?: Release): void {
+ const value = release?.hash ?? hashInput.value;
+ invalidateProgram();
+ chooseProgram(release ? `package:${release.name}` : 'hash', release?.version);
+ hashInput.value = value;
try {
- sourceEditor.hidden = true; el('source-notes').hidden = false; showRelease();
- const hash = bundleHash(hashInput.value); selected = undefined; selection = { hash };
- for (const button of document.querySelectorAll('[data-demo]')) button.setAttribute('aria-pressed', 'false');
- argsInput.value = '[]'; resultCodec.value = 'tree'; mode.value = 'pure';
- el('source').textContent = 'Verifying registry package…'; newWorker(false);
+ const hash = bundleHash(value); selection = { hash };
+ argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tree'; mode.value = 'pure';
+ showRelease(release); el('source').textContent = 'Verifying registry package…'; newWorker(false);
} catch (error) { finish(error instanceof Error ? error.message : String(error), true); }
-};
-hashInput.oninput = () => { showRelease(); draft = false; scratch = false; originalSource = undefined; sourceEditor.hidden = true; el('source-notes').hidden = false; el('package-info').textContent = 'Load this package to verify its hash and choose a definition.'; clearTreeExport(); dispose(); loaded = false; selection = undefined; selected = undefined; controls(false); el('status').textContent = 'Load package'; };
+}
+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); };
runButton.onclick = () => {
if (draft) { if (compiled) runDraft(); else compileDraft(true); return; }
if (!loaded || !selection) return;
clearOutput();
try {
- if (argsInput.value.length > 16_000) throw new Error('Argument JSON limit exceeded');
- const args = literals(JSON.parse(argsInput.value) as unknown), output: OutputType = outputType(resultCodec.value);
+ 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 };
if (worker) { controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage(savedRun); }
else newWorker(true);
@@ -248,34 +267,62 @@ shareButton.onclick = async () => {
catch { el('result').textContent = url.href; }
};
window.addEventListener('pagehide', dispose);
-function populatePackages(): void {
- const packages = el<HTMLSelectElement>('package-name');
- packages.replaceChildren();
- const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = 'Choose a package'; packages.append(placeholder);
+function chooseProgram(value: string, version?: string): void {
+ programPicker.value = value;
+ const name = value.startsWith('package:') ? value.slice(8) : undefined;
+ el('program-release').hidden = name === undefined;
+ el('program-hash').hidden = value !== 'hash';
+ if (name && versionPackage !== name) {
+ versionPackage = name;
+ versionPicker.replaceChildren();
+ for (const release of releases.filter(r => r.name === name)) {
+ const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versionPicker.append(option);
+ }
+ }
+ if (version !== undefined) versionPicker.value = version;
+ programSummary();
+}
+function programSummary(): void {
+ const label = programPicker.selectedOptions[0]?.textContent ?? 'Program';
+ const name = programPicker.value.startsWith('package:') ? `${label}@${versionPicker.value}`
+ : programPicker.value === 'hash' ? `bundle ${selection?.hash ?? (hashInput.value || '(enter a hash)')}` : label;
+ el('program-selection-status').textContent = draft ? (scratch ? 'Local scratch draft. Not a published bundle.' : `Local draft based on ${name}. Published exports are unavailable until restored.`)
+ : loaded && selection ? `Loaded: ${name}. Loading does not run it.`
+ : busy && selection ? `Loading: ${name}. Verifying bundle bytes…`
+ : programPicker.value === 'hash' ? 'Enter a bundle hash, then Load bundle. No program is loaded.'
+ : programPicker.value.startsWith('package:') ? `Choose a version of ${label}, then Load package. No program is loaded.`
+ : 'Choose a program to load. Loading does not run it.';
+}
+function populatePrograms(): void {
+ programPicker.replaceChildren();
+ const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.textContent = 'Choose a program'; programPicker.append(placeholder);
+ const demos = document.createElement('optgroup'); demos.label = 'Examples';
+ for (const entry of entries) {
+ const option = document.createElement('option'); option.value = `demo:${entry.id}`; option.textContent = entry.title; demos.append(option);
+ }
+ const packages = document.createElement('optgroup'); packages.label = 'Published packages';
for (const name of new Set(releases.map(r => r.name))) {
- const option = document.createElement('option'); option.value = name; option.textContent = name; packages.append(option);
+ const option = document.createElement('option'); option.value = `package:${name}`; option.textContent = name; packages.append(option);
}
- packages.onchange = () => {
- const versions = el<HTMLSelectElement>('package-version'); versions.replaceChildren();
- el<HTMLButtonElement>('load-release').disabled = !packages.value;
- for (const release of releases.filter(r => r.name === packages.value)) {
- const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versions.append(option);
- }
- };
- packages.dispatchEvent(new Event('change'));
+ const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…';
+ programPicker.append(demos, packages, hash); programPicker.disabled = false;
}
function showRelease(release?: Release): void {
- const packages = el<HTMLSelectElement>('package-name'); packages.value = release?.name ?? ''; packages.dispatchEvent(new Event('change'));
- el<HTMLSelectElement>('package-version').value = release?.version ?? '';
- el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'Direct bundle pin (no name/version selected).';
+ el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'No named release selected. Direct bundle hashes pin the whole file.';
}
+programPicker.onchange = () => {
+ const value = programPicker.value;
+ if (value.startsWith('demo:')) {
+ const entry = entries.find(e => e.id === value.slice(5)); if (entry) select(entry);
+ } else { chooseProgram(value); if (value === 'hash') hashInput.value = ''; invalidateProgram(); if (value === 'hash') hashInput.focus(); }
+};
+versionPicker.onchange = invalidateProgram;
el<HTMLButtonElement>('load-release').onclick = () => {
- const name = el<HTMLSelectElement>('package-name').value, version = el<HTMLSelectElement>('package-version').value;
- const release = releases.find(r => r.name === name && r.version === version); if (!release) return;
+ const name = programPicker.value.startsWith('package:') ? programPicker.value.slice(8) : undefined;
+ const release = releases.find(r => r.name === name && r.version === versionPicker.value); if (!release) return;
const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
- if (demo) select(demo);
- else { hashInput.value = release.hash; el<HTMLButtonElement>('load').click(); }
- showRelease(release);
+ if (demo) select(demo, release);
+ else loadBundle(release);
};
downloadButton.onclick = () => {
if (draft || !loaded || !selection) return;
@@ -313,13 +360,14 @@ el<HTMLButtonElement>('recover-draft').onclick = () => {
if (saved.demo && !entry) throw new Error('The saved demo environment is no longer in this catalogue. Use Download saved source to recover your text; it has not been compiled against a substitute environment.');
const output = outputType(saved.output);
dispose(); clearOutput(); clearTreeExport();
+ chooseProgram(entry ? `demo:${entry.id}` : '');
draft = true; scratch = !saved.demo; selected = entry; originalSource = undefined; selection = undefined; loaded = false;
showRelease(entry ? releases.find(r => r.hash === entry.hash) : undefined); hashInput.value = entry?.hash ?? '';
el('package-info').textContent = entry ? `Recovered draft based on ${entry.title}. No published executable loaded.` : 'Recovered scratch program. No published package selected.';
sourceEditor.value = saved.source; sourceEditor.hidden = false; el('source-notes').hidden = true;
- preferredName = saved.name; exportsInput.replaceChildren(); argsInput.value = saved.arguments; resultCodec.value = output; mode.value = saved.mode;
- // A recovered draft does not inherit a previously selected risky profile.
- const profile = el<HTMLInputElement>('compiler-uncapped'); profile.checked = false; profile.dispatchEvent(new Event('change'));
+ preferredName = saved.name; exportsInput.replaceChildren(); argsInput.value = saved.arguments; argumentsUI.refresh(true); resultCodec.value = output; mode.value = saved.mode;
+ // Recovery uses the page default, never stored resource settings.
+ const profile = el<HTMLInputElement>('compiler-uncapped'); profile.checked = true; profile.dispatchEvent(new Event('change'));
el('source-label').textContent = entry ? `Recovered draft · ${entry.title}` : 'Recovered scratch program · contract helpers';
controls(false); el('status').textContent = 'Recovered draft: not compiled';
} catch (error) { el('draft-save-status').textContent = error instanceof Error ? error.message : String(error); }
@@ -403,8 +451,7 @@ function compileDraft(runAfterCompile: boolean): void {
function runDraft(): void {
if (!compiled || !worker) return;
try {
- if (argsInput.value.length > 16_000) throw new Error('Argument JSON limit exceeded');
- const args = literals(JSON.parse(argsInput.value) as unknown), output = outputType(resultCodec.value);
+ const args = argumentsUI.read(), output = outputType(resultCodec.value);
clearOutput(); controls(true); runClock(); el('status').textContent = 'Running';
worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked });
} catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; }
@@ -418,10 +465,11 @@ 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('');
dispose(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined;
exportsInput.replaceChildren(); preferredName = example.name;
sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source;
- argsInput.value = JSON.stringify(example.args, null, 2); resultCodec.value = example.output; mode.value = example.mode;
+ argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); resultCodec.value = example.output; mode.value = example.mode;
saveDraft(); controls(false); el('source-label').textContent = 'Scratch program · contract helpers'; el('status').textContent = 'Ready to compile';
};
function loadFragment(): boolean {
@@ -432,9 +480,10 @@ function loadFragment(): boolean {
preferredExport = fragment.get('export') ?? undefined;
if (local) select(local);
else {
+ chooseProgram('hash');
showRelease(); draft = false; scratch = false; originalSource = undefined;
dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined;
- argsInput.value = '[]'; resultCodec.value = 'tree'; mode.value = 'pure';
+ argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tree'; mode.value = 'pure';
selection = { hash }; hashInput.value = hash; newWorker(false);
}
return true;
@@ -465,11 +514,7 @@ 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);
- populatePackages(); recoveryButtons();
- for (const entry of entries) {
- const button = document.createElement('button'); button.type = 'button'; button.dataset['demo'] = entry.id; button.textContent = entry.title;
- button.setAttribute('aria-pressed', 'false'); button.onclick = () => select(entry); el('demo-picker').append(button);
- }
+ populatePrograms(); recoveryButtons();
if (!packagePane() || selection || draft) return;
if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); select(initial); }
} catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
@@ -484,7 +529,6 @@ window.addEventListener('pane-change', () => {
if (packagePane()) {
document.querySelector(`#${activePane()} [data-package-slot]`)!.append(el('package-selection'));
if (!initialized) void initializePackages();
- else if (!draft && entries.length && !selection) { if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (initial) select(initial); } }
else if (!draft && entries.length && !loaded && selection) newWorker(false);
}
});
diff --git a/ext/demo-site/src/arguments-ui.ts b/ext/demo-site/src/arguments-ui.ts
@@ -0,0 +1,68 @@
+import { literals, type Literal } from './codecs.js';
+import { parseLiteral, printLiteral } from './literal-input.js';
+
+export function argumentEditor(raw: HTMLTextAreaElement) {
+ const container = document.getElementById('argument-fields')!;
+ const add = document.getElementById('add-argument') as HTMLButtonElement;
+ const rawMode = document.getElementById('raw-arguments') as HTMLInputElement;
+ let snapshot = '', disabled = false;
+ let rows: { input: HTMLTextAreaElement; original?: Literal; text: string }[] = [];
+ function render(): void {
+ container.replaceChildren();
+ if (!rows.length) { const hint = document.createElement('p'); hint.className = 'hint'; hint.textContent = 'No arguments. This definition already contains its inputs.'; container.append(hint); }
+ rows.forEach((row, i) => {
+ const wrapper = document.createElement('div'); wrapper.className = 'argument-row';
+ const label = document.createElement('label'); label.htmlFor = `argument-${i}`; label.textContent = `Argument ${i + 1}`;
+ row.input.id = label.htmlFor; row.input.rows = 2; row.input.maxLength = 16000; row.input.spellcheck = false; row.input.disabled = disabled;
+ const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Remove'; remove.disabled = disabled;
+ remove.setAttribute('aria-label', `Remove argument ${i + 1}`);
+ remove.onclick = () => { rows.splice(i, 1); render(); sync(); };
+ row.input.oninput = sync;
+ wrapper.append(label, row.input, remove); container.append(wrapper);
+ });
+ container.hidden = rawMode.checked; add.hidden = rawMode.checked; add.disabled = disabled || rows.length >= 16;
+ raw.hidden = !rawMode.checked; rawMode.disabled = disabled;
+ }
+ function values(): Literal[] {
+ return literals(rows.map(row => row.original && row.input.value === row.text ? row.original : parseLiteral(row.input.value)));
+ }
+ function sync(): void {
+ try { raw.value = JSON.stringify(values()); snapshot = raw.value; } catch { /* Retain invalid field text for correction; read reports it. */ }
+ }
+ function refresh(reset = false): void {
+ if (reset) rawMode.checked = false;
+ if (!reset && raw.value === snapshot) { render(); return; }
+ try {
+ rows = literals(JSON.parse(raw.value) as unknown).map(original => {
+ const input = document.createElement('textarea'), text = printLiteral(original); input.value = text; return { input, original, text };
+ });
+ snapshot = raw.value;
+ } catch { rawMode.checked = true; }
+ render();
+ }
+ add.onclick = () => { const input = document.createElement('textarea'); input.value = 't'; rows.push({ input, text: 't' }); render(); sync(); input.focus(); };
+ raw.oninput = () => { snapshot = ''; };
+ rawMode.onchange = () => {
+ if (!rawMode.checked) refresh();
+ else {
+ try { raw.value = JSON.stringify(values()); snapshot = raw.value; }
+ catch (error) {
+ rawMode.checked = false;
+ document.getElementById('result')!.textContent = `Correct the argument before switching editors: ${error instanceof Error ? error.message : String(error)}`;
+ }
+ }
+ render();
+ };
+ refresh();
+ return {
+ refresh,
+ disable(value: boolean) { disabled = value; refresh(); },
+ read(): Literal[] {
+ if (raw.value.length > 16000) throw new Error('Argument text limit exceeded');
+ if (rawMode.checked || raw.value !== snapshot) return literals(JSON.parse(raw.value) as unknown);
+ const result = values();
+ if (JSON.stringify(result).length > 16000) throw new Error('Argument text limit exceeded');
+ sync(); return result;
+ },
+ };
+}
diff --git a/ext/demo-site/src/limits-ui.ts b/ext/demo-site/src/limits-ui.ts
@@ -25,11 +25,11 @@ for (const runner of ['package', 'compiler'] as const) {
apply.type = 'submit'; apply.textContent = 'Apply limits'; restore.type = 'button'; restore.textContent = 'Restore defaults';
const error = document.createElement('p'); error.className = 'limits-error'; error.setAttribute('role', 'status');
actions.append(apply, restore); form.append(actions, error); container.replaceWith(form);
- committed.set(runner, executionSettings(defaults, runner));
+ committed.set(runner, executionSettings(defaults, runner, true));
function update(): void {
const settings = readSettings(runner);
const customized = Object.entries(defaults).some(([key, value]) => settings.limits[key as keyof typeof defaults] !== value);
- summary.textContent = `Advanced: ${settings.uncapped ? 'browser limits; execution guards off' : customized ? 'custom resource guards' : 'resource guards and diagnostics'}`;
+ summary.textContent = `Resource guards${settings.uncapped ? ' (off)' : customized ? ' (custom)' : ' (on)'}`;
for (const input of fields.values()) input.disabled = settings.uncapped;
apply.disabled = settings.uncapped;
document.getElementById(`${runner}-resource-mode`)!.textContent = settings.uncapped ? 'Use browser limits' : 'Standard';
@@ -59,7 +59,7 @@ for (const runner of ['package', 'compiler'] as const) {
};
restore.onclick = () => {
for (const [key, input] of fields) input.value = String(defaults[key as keyof typeof defaults]);
- checkbox.checked = false; publish(executionSettings(defaults, runner));
+ checkbox.checked = true; publish(executionSettings(defaults, runner, true));
};
update();
}
diff --git a/ext/demo-site/src/literal-input.ts b/ext/demo-site/src/literal-input.ts
@@ -0,0 +1,93 @@
+import { literals, type Literal } from './codecs.js';
+
+// Deliberately a data-only subset, not a second source compiler. No names,
+// arbitrary applications, comments, or effects are evaluated in argument fields.
+export function parseLiteral(source: string): Literal {
+ if (source.length > 16_000) throw new Error('Argument text limit exceeded');
+ let at = 0, items = 0;
+ const space = () => { while (source[at] === ' ' || source[at] === '\n') at++; };
+ const fail = (): never => { throw new Error(`Expected a literal at character ${at + 1}`); };
+ function atom(depth: number): Literal {
+ if (++items > 4096 || depth > 16) throw new Error('Literal nesting/item limit exceeded');
+ space(); const start = at, token = source[at++];
+ if (token === '"') {
+ let value = '';
+ while (at < source.length) {
+ const char = source[at++];
+ if (char === '"') {
+ const scalars = Array.from(value);
+ if (scalars.length > 4096 || scalars.some(c => { const n = c.codePointAt(0)!; return n >= 0xd800 && n <= 0xdfff; }))
+ throw new Error('Expected bounded Unicode scalar text');
+ return { type: 'string', value };
+ }
+ if (char === '\\') {
+ const escapes: Record<string, string> = { n: '\n', t: '\t', r: '\r', f: '\f', b: '\b', '\\': '\\', '"': '"', "'": "'" };
+ const escaped = escapes[source[at++]!]; if (escaped === undefined) fail(); value += escaped;
+ } else value += char;
+ }
+ fail();
+ }
+ if (token === '[') {
+ const value: Literal[] = []; space();
+ while (source[at] !== ']') { if (at >= source.length) fail(); value.push(atom(depth + 1)); space(); }
+ at++; return { type: 'list', value };
+ }
+ if (token === '(') { const value = expression(depth + 1); space(); if (source[at++] !== ')') fail(); return value; }
+ if (token === 't') return { type: 'tree', value: '0' };
+ if (token && /[0-9]/u.test(token)) {
+ while (source[at] && /[0-9]/u.test(source[at]!)) at++;
+ return literals([{ type: 'number', value: source.slice(start, at).replace(/^0+(?=\d)/u, '') }])[0]!;
+ }
+ return fail();
+ }
+ function expression(depth: number): Literal {
+ space(); const constructor = source[at] === 't'; const first = atom(depth);
+ if (!constructor) return first;
+ const children: Literal[] = []; space();
+ while (at < source.length && source[at] !== ')' && source[at] !== ']') {
+ if (children.length === 2) fail(); children.push(atom(depth + 1)); space();
+ }
+ // Constructor children can contain encoded scalar/list data without evaluation.
+ const ternary = (literal: Literal): string => {
+ switch (literal.type) {
+ case 'tree': return literal.value;
+ case 'bool': return literal.value ? '10' : '0';
+ case 'number': {
+ let n = BigInt(literal.value), result = '';
+ while (n) { result += n & 1n ? '210' : '20'; n >>= 1n; }
+ return result + '0';
+ }
+ case 'string': return ternary({ type: 'list', value: Array.from(literal.value).map(c => ({ type: 'number', value: String(c.codePointAt(0)) })) });
+ case 'bytes': return ternary({ type: 'list', value: literal.value.map(n => ({ type: 'number', value: String(n) })) });
+ case 'list': return literal.value.map(v => '2' + ternary(v)).join('') + '0';
+ }
+ };
+ const value = String(children.length) + children.map(ternary).join('');
+ if (value.length > 8192) throw new Error('Tree literal limit exceeded');
+ return { type: 'tree', value };
+ }
+ const value = expression(0); space(); if (at !== source.length) fail();
+ return literals([value])[0]!;
+}
+
+export function printLiteral(literal: Literal): string {
+ switch (literal.type) {
+ case 'number': return literal.value;
+ case 'string': return '"' + literal.value.replace(/[\\"\n\t\r\f\b]/gu, c => ({ '\\': '\\\\', '"': '\\"', '\n': '\\n', '\t': '\\t', '\r': '\\r', '\f': '\\f', '\b': '\\b' })[c]!) + '"';
+ case 'bool': return literal.value ? '(t t)' : 't';
+ case 'bytes': return '[' + literal.value.join(' ') + ']';
+ case 'list': return '[' + literal.value.map(printLiteral).join(' ') + ']';
+ case 'tree': {
+ let at = 0; const value = literal.value;
+ function read(depth: number): string {
+ if (depth > 16) throw new Error('Use raw JSON for deeply nested trees');
+ const tag = value[at++];
+ if (tag === '0') return 't';
+ if (tag === '1') return `(t ${read(depth + 1)})`;
+ if (tag === '2') return `(t ${read(depth + 1)} ${read(depth + 1)})`;
+ throw new Error('Invalid ternary tree');
+ }
+ const result = read(0); if (at !== literal.value.length) throw new Error('Trailing ternary data'); return result;
+ }
+ }
+}
diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css
@@ -85,7 +85,6 @@ footer { padding-top:25px; padding-bottom:35px; border-top:1px solid var(--line)
@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; } }
-.package-controls { border:1px solid var(--line); border-radius:8px; padding:18px 20px; background:var(--panel); margin-bottom:18px; }
#bundle-hash { font:12px ui-monospace,monospace; }
#package-info { overflow-wrap:anywhere; }
.application-fields { padding-top:18px; }
@@ -169,10 +168,10 @@ details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; }
#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(2,minmax(0,1fr)); }
+ .workspace-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
.workspace-tabs button { min-height:44px; }
- .package-controls .input-row { flex-wrap:wrap; }
- .package-controls .input-row input { flex-basis:100%; }
+ .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; }
@@ -195,3 +194,39 @@ button.primary.draft { background:#8b581b; }
.resource-choice { border:1px solid var(--line); border-radius:8px; padding:12px 16px; margin:12px 0; }
.resource-choice p { margin:4px 0 8px; }
.draft-recovery { padding:0 20px; }
+
+/* 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; }
+#program-selection-status { overflow-wrap:anywhere; margin:12px 0; }
+.bundle-details { margin:12px 0 20px; }
+.bundle-details > summary { font-size:14px; font-weight:600; }
+.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; }
+.run-tools .execution-limits { margin:12px 0; }
+.trace-panel > summary { padding:14px 20px; }
+.draft-recovery { margin:10px 0; }
+.draft-recovery .hint:empty { display:none; }
+@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; }
diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs
@@ -170,6 +170,39 @@ try {
assert.equal(await evaluate("document.querySelectorAll('[role=tab]').length"),3);
assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('package-limit-'+k).value)"),['250000','20000000','50000','10']);
assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('compiler-limit-'+k).value)"),['6000000','0','100000','0']);
+ 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…');
+ 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('run').disabled"),true);
+ assert.match(await evaluate("document.getElementById('program-selection-status').textContent"),/No program is loaded/);
+ for(const name of [...new Set(packageIndex.releases.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),`versions belong to ${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');
console.log('PASS Ecosystem default / deferred package load / ARIA keyboard tabs / dynamic active height / editable default limits');
assert.equal(requests.some(path=>path.endsWith('/compiler.json')),false);
assert.equal(await evaluate("document.getElementById('compile').disabled"),true);
@@ -184,6 +217,27 @@ try {
if(expected.endsWith('tree graph'))assert.ok(resultText.startsWith(expected));
else assert.equal(resultText,expected);
}
+ assert.equal(await evaluate("document.getElementById('uncapped').checked"),true);
+ assert.equal(await evaluate("document.getElementById('compiler-uncapped').checked"),true);
+ assert.equal(await evaluate("document.getElementById('uncapped').closest('details').open"),false);
+ 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()");
@@ -231,7 +285,7 @@ try {
assert.ok(value.startsWith('Returned record: '));return JSON.parse(value.slice('Returned record: '.length));
};
for(const [demo,exports] of [['shipping',['v1','v2']],['events',['vendorA','vendorB']],['registration',['main']],['migration',['v1','v2']]]){
- await evaluate(`document.querySelector('[data-demo=${demo}]').click()`);await wait(status('Ready'));
+ await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready'));
await assertSourceFill();
const hash=catalogue.find(e=>e.id===demo).hash;
assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(hash));
@@ -252,7 +306,7 @@ try {
}
console.log('PASS practical record demos / authenticated hashes / policy and schema versions / equivalent adapters / field diagnostics');
- await evaluate("document.querySelector('[data-demo=microscope]').click()");await wait(status('Ready'));
+ 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);
@@ -278,7 +332,7 @@ try {
await evaluate("document.getElementById('uncapped').checked=true;document.getElementById('uncapped').dispatchEvent(new Event('change'))");
await wait(status('Ready'));await click('run');await wait(status('Finished'));
assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: true');
- assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Advanced: browser limits; execution guards off');
+ assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Resource guards (off)');
const unlimitedPackage=await evaluate(`new Promise((resolve,reject)=>{
const w=new Worker(new URL('./dist/worker.js',location.href),{type:'module'});
w.onmessage=({data})=>{
@@ -314,14 +368,14 @@ try {
assert.equal(await evaluate("document.getElementById('status').textContent"),'Ready');
assert.match(await evaluate("document.querySelector('#playground .limits-error').textContent"),/nodes/);
await evaluate("document.getElementById('package-limit-nodes').value='300000';document.querySelector('#playground .limits-editor').requestSubmit()");await wait(status('Ready'));
- assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Advanced: custom resource guards');
+ assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Resource guards (custom)');
await evaluate("document.querySelector('#playground .limits-editor button[type=button]').click()");await wait(status('Ready'));
assert.equal(await evaluate("document.getElementById('package-limit-nodes').value"),'250000');
console.log('PASS custom Worker budgets / finite fuel / small GC thresholds / malformed settings / Apply validation / restore');
- await evaluate("document.querySelector('[data-demo=rgb]').click()");await wait(status('Ready'));
+ 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'}]}]))}`);
@@ -329,7 +383,7 @@ try {
assert.match(await evaluate("document.getElementById('result').textContent"),/not a canonical byte/);
console.log('PASS tricu RGB validation / failing literal contract');
- await evaluate("document.querySelector('[data-demo=greet]').click()");await wait(status('Ready'));
+ 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'));
@@ -367,13 +421,16 @@ try {
console.log('PASS stepping / Stop / reset / bounded and HTML-like input');
await click('run');await wait(status('Waiting for input'));
- await evaluate("document.querySelector('[data-demo=microscope]').click()");await wait(status('Ready'));
+ 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('bundle-hash').value=${JSON.stringify(hash)};document.getElementById('load').click()`);
+ 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('program-selection-status').textContent"),new RegExp('Loaded: bundle '+hash));
};
await loadHash(published.append);
assert.equal(await evaluate("document.getElementById('mode').value"),'pure');
@@ -426,7 +483,7 @@ try {
console.log('PASS exponential DAG / lazy discarded divergence / actual divergent program budget');
for(const [hash,expected] of [['f'.repeat(64),/SHA-256 mismatch/],['a'.repeat(64),/download limit/],[published.invalid,/reserved/]]){
- await evaluate(`document.getElementById('bundle-hash').value=${JSON.stringify(hash)};document.getElementById('load').click()`);
+ 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);
@@ -437,20 +494,28 @@ try {
assert.equal(await evaluate("document.getElementById('terminal').textContent"),'');
console.log('PASS share selection loads metadata without executing');
- await evaluate("document.getElementById('package-name').value='tricu-base';document.getElementById('package-name').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'");
+ 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');
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('program-selection-status').textContent"),/Loaded: 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('program-selection-status').textContent"),/Loaded: 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('package-name').value='intro';document.getElementById('package-name').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'");
+ await evaluate("document.getElementById('program-picker').value='package: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"),'package: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);`);
@@ -487,7 +552,7 @@ try {
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.querySelector('[data-demo=${demo}]').click()`);await wait(status('Ready'));
+ 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('run');await wait(status('Finished')+' || '+status('Stopped'),60);
const result=await evaluate("document.getElementById('result').textContent");
@@ -619,7 +684,7 @@ try {
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.querySelector('[data-demo=microscope]').click()");await wait(status('Ready'));
+ 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'));
@@ -640,6 +705,7 @@ try {
terminate() { this.spin?.terminate(); super.terminate(); globalThis.testWorkerTerminated = true; }
};` });
await send('Page.reload'); await openPlayground();
+ await evaluate("document.getElementById('uncapped').checked=false;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready'));
await click('run'); await wait(status('Stopped'), 13);
assert.match(await evaluate("document.getElementById('result').textContent"), /time limit exceeded/);
assert.equal(await evaluate('globalThis.testWorkerTerminated'), true);
@@ -752,12 +818,30 @@ try {
assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,`${id} at ${width}px`);
}
}
+ 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});
for(const id of ['tab-playground']) {
- await click(id);const before=await paneHeight();
+ await click(id);await evaluate("document.querySelector('#playground .run-tools').open=true");const before=await paneHeight();
await evaluate("document.querySelector('.workspace-panel.is-active .execution-limits').open=true");
assert.ok((await paneHeight())>before);await assertPaneFits();
assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true);
@@ -774,7 +858,7 @@ try {
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('#compiler-settings').open=true");
+ 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'));
@@ -802,13 +886,13 @@ try {
await reloadForRecovery();
await click('tab-playground');await wait(status('Ready'));
assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compilerRequests,'reload does not compile');
- assert.equal(await evaluate("document.getElementById('compiler-uncapped').checked"),false);
+ assert.equal(await evaluate("document.getElementById('compiler-uncapped').checked"),true);
await click('recover-draft');
assert.equal(await evaluate("document.getElementById('source-editor').value"),savedSource);
assert.equal(await evaluate("document.getElementById('status').textContent"),'Recovered draft: not compiled');
assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compilerRequests,'recovery does not compile');
await click('compile');await wait(compilerStatus('Compiled'));
- assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/memory guard active/);
+ assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/browser limits; execution guards off/);
// Failed storage is visible and compilation requires a separate explicit override.
await evaluate("window._setItem=Storage.prototype.setItem;Storage.prototype.setItem=()=>{throw new DOMException('blocked','QuotaExceededError')};window.confirm=()=>false;document.getElementById('source-editor').value+='\\n-- not saved';document.getElementById('source-editor').dispatchEvent(new Event('input'))");
const beforeFailedSave=requests.filter(path=>path.endsWith('/compiler.json')).length;
@@ -823,7 +907,7 @@ try {
assert.equal(await evaluate("document.getElementById('recover-draft').hidden"),true);
assert.match(await evaluate("document.getElementById('source-editor').value"),/not saved/);
// Curated recovery must keep its environment, never become a scratch program.
- await evaluate("document.querySelector('[data-demo=shipping]').click()");await wait(status('Ready'));
+ await evaluate("document.getElementById('program-picker').value='demo:shipping';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready'));
await evaluate("document.getElementById('source-editor').value='answer = stringField \"status\" \"recovered\"';document.getElementById('arguments').value='[]';document.getElementById('output-type').value='list';document.getElementById('source-editor').dispatchEvent(new Event('input'))");
assert.equal(await evaluate("JSON.parse(localStorage.getItem('arboricx.local-draft.v1')).demo.environment"),'practical');
await reloadForRecovery();
@@ -836,7 +920,7 @@ try {
assert.match(await evaluate("document.getElementById('draft-save-status').textContent"),/no longer in this catalogue/);
assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeMissingContext);
await click('forget-draft');
- console.log('PASS browser-limits consent / saved source before compile / reload recovery in Standard / no auto-execution / storage failure / curated environment / missing-context rejection / forget');
+ console.log('PASS browser-limits consent / saved source before compile / reload recovery with default guards off / no auto-execution / storage failure / curated environment / missing-context rejection / forget');
assert.deepEqual(exceptions, []);
} finally {
diff --git a/ext/demo-site/test/literal-input.test.mjs b/ext/demo-site/test/literal-input.test.mjs
@@ -0,0 +1,27 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {parseLiteral,printLiteral} from '../dist/literal-input.js';
+import {Graph} from '../dist/graph.js';
+import {makeLiteral} from '../dist/codecs.js';
+const tree = value => { const g=new Graph();return g.toTernary(makeLiteral(g,value)); };
+test('data-only source literals preserve canonical codec trees',()=>{
+ for(const [source,expected] of [
+ ['00042',{type:'number',value:'42'}],
+ ['"λ雪🙂\\n\\t\\r\\f\\b\\\\\\\"\\\'"',{type:'string',value:'λ雪🙂\n\t\r\f\b\\"\''}],
+ ['[1 "hello" [2 3] (t t)]',{type:'list',value:[{type:'number',value:'1'},{type:'string',value:'hello'},{type:'list',value:[{type:'number',value:'2'},{type:'number',value:'3'}]},{type:'bool',value:true}]}],
+ ['t',{type:'tree',value:'0'}], ['(t t)',{type:'bool',value:true}],
+ ['(t 3 [1 2])',{type:'tree',value:'221021002221002201000'}],
+ ]) {
+ if(source==='(t 3 [1 2])') { assert.equal(tree(parseLiteral(source)), '2'+tree({type:'number',value:'3'})+tree({type:'list',value:[{type:'number',value:'1'},{type:'number',value:'2'}]})); }
+ else assert.equal(tree(parseLiteral(source)),tree(expected),source);
+ }
+});
+test('every preset literal has a data-equivalent printable input',async()=>{
+ const {readFile}=await import('node:fs/promises');
+ const catalogue=JSON.parse(await readFile(new URL('../catalogue.json',import.meta.url)));
+ for(const entry of catalogue)for(const preset of [entry,...Object.values(entry.presets??{})])
+ for(const literal of preset.arguments)assert.equal(tree(parseLiteral(printLiteral(literal))),tree(literal));
+});
+test('literal syntax rejects source evaluation, bad escapes, trailing tokens and excessive nesting',()=>{
+ for(const source of ['id','true','false','(x : x)','first 1 2','t t t t','[1,2]','[1 | t]','"\\u0041"','"\\q"','"unterminated','(t','[1','42 7','1\t','['.repeat(18)+']'.repeat(18),'1'.repeat(16001),'(t '+ '9'.repeat(1025)+')','"\ud800"','(t "\ud800")'])assert.throws(()=>parseLiteral(source),undefined,source);
+});
diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs
@@ -103,7 +103,24 @@ test('hero pronunciation, ecosystem order, and desktop shape alignment',async()=
test('unified authoring requires informed compilation and retains published identity',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','compile-consent','confirm-compile','cancel-compile','download-bundle','package-name','package-version'])assert.match(html,new RegExp(`id="${id}"`));
+ for(const id of ['source-editor','authorship','restore-source','compile-consent','confirm-compile','cancel-compile','download-bundle','program-picker','package-version'])assert.match(html,new RegExp(`id="${id}"`));
assert.match(html,/can take minutes/);assert.match(html,/Your draft is not published/);
assert.match(html,/Use browser limits/);assert.match(html,/may crash or reload the tab/);
});
+
+test('primary workspace is compact and guards-off defaults stay inside Advanced',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,/<details class="bundle-details"><summary>Bundle details/);
+ assert.doesNotMatch(html,/id="demo-picker"|id="package-name"|Published packages and provenance/);
+ for(const id of ['program-release','program-hash','program-selection-status'])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('<p class="hint"><a href="#export-definition"'));
+ for(const id of ['uncapped','compiler-uncapped'])assert.match(advanced,new RegExp(`id="${id}" type="checkbox" checked`));
+ 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">/);
+});