tricu

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

commit e3aeac9a7d10aacf5376ce9e8bbc5648565c4fe1
parent 84a6b0f6732f6c59c561cebd48374854d6f249a9
Author: James Eversole <james@eversole.co>
Date:   Tue, 13 Oct 2026 12:54:16 -0500

Make playground source-first and deduplicate program choices

Diffstat:
Mext/demo-site/README.md | 59+++++++++++++++++++++++++++++++++++++++--------------------
Mext/demo-site/dist/app.js | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mext/demo-site/dist/arguments-ui.js | 6++++--
Aext/demo-site/dist/demo-inputs.js | 14++++++++++++++
Mext/demo-site/index.html | 46+++++++++++++++++++++++++++-------------------
Mext/demo-site/src/app.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mext/demo-site/src/arguments-ui.ts | 6++++--
Aext/demo-site/src/demo-inputs.ts | 14++++++++++++++
Mext/demo-site/style.css | 40++++++++++++++++++++++++++++++++--------
Mext/demo-site/test/browser.mjs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Aext/demo-site/test/demo-inputs.test.mjs | 12++++++++++++
Mext/demo-site/test/site.test.mjs | 33+++++++++++++++++++++++++++++----
12 files changed, 369 insertions(+), 134 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -68,31 +68,50 @@ The package selector reads `packages.json`, a versioned static index of exact name/version → native manifest + whole-bundle pins. A single **Program** selector is shared by Playground and Export, with grouped **Examples**, **Published packages**, and a visible **Load by bundle hash…** option. Examples load their -pinned bundle and suggested inputs. Published packages reveal the version picker +pinned bundle and suggested inputs. Demo names appear only under Examples, not +again under Published packages. Examples with multiple releases show the version +picker and **Load package**; changing versions invalidates prior results, and +older releases load their exact bundle without borrowing current editable source. +Single-release examples omit the version controls. The Published packages group contains +libraries and other non-demo packages, which reveal the version picker and **Load package**. Hash loading reveals **Bundle hash (SHA-256)** and **Load bundle**, accepting arbitrary browser-compatible bundles from this site's registry. -**Bundle details** contains verification and named-release metadata, not loaders. +**Bundle details** is compact, always-visible verification and named-release metadata, not a disclosure or loader. There is no redundant selection-status line. The three routes are mutually exclusive. Changing the program, package version, or entered hash discards the previous executable/result/export; Run and Export -stay disabled until the new bundle is verified. A visible status distinguishes +stay disabled until the new bundle is verified. Bundle verification details and source authorship distinguish pending choices from loaded programs and local drafts. Pending choices persist when switching panels without silently reloading an example. Selecting a release or following a selection link never runs a program. Browsing arbitrary packages does not imply editable source or browser module resolution. -Curated demos have a source editor. Exact original source means **Run published**: -the authenticated bundle is executed, not recompiled. An edit switches to -**Local draft** and **Compile & run draft**, with text and color distinguishing -its authorship. Editing discards any previously compiled heap and result. A failed -draft never falls back to an earlier draft or the published executable. Restore -published source reinstates the original bundle path. Compile-only remains -available, and subsequent draft runs reuse the compiled definitions. - -Compile and Compile & run start directly when clicked; there is no confirmation -popup. An inline notice in the draft editor explains that compilation runs locally -and can use extra memory, with directions to **Advanced** below Run for optional -memory, work and time limits. An elapsed indicator and Stop remain available; +The workspace shares the hero’s width and outer margins, and uses a +stacked source-first layout: Program and bundle details, full-width +**Source and editing**, then **Try this program** with definition, inputs, Run +and results. Both source and Try start expanded and can be collapsed independently. +Interaction trace is a child of Try, not a separate top-level section. Input help/raw JSON, result interpretation, IO mode and +resource settings live under **Advanced**. Tree results have a separate inspection +disclosure instead of dominating the result area. Pure programs do not show an +empty IO terminal. Curated input labels describe the published argument order; +arbitrary bundles and local drafts retain generic positional labels. + +All selected programs open source by default; reading comes before interaction. Published source is read-only until **Edit source** is +clicked. Opening the editor alone keeps **Run published**: the authenticated +bundle is executed, not recompiled. Changing text switches to **Local draft**, +invalidates any compiled heap/result, and disables **Run draft** until an explicit +**Compile draft** in the source footer succeeds. Compilation does not run the result. +The footer also provides Stop and a link that opens Try and jumps to its Run controls. Subsequent draft +runs reuse the compiled definitions; edits, errors and discarded sessions require +compilation again. A failed draft never falls back to an earlier draft or the +published executable. **Restore published source** reinstates the original bundle +path and read-only source. There is no combined compile-and-run button. + +**Compile draft** starts directly when clicked; there is no confirmation popup. +The inline notice identifies the JavaScript Tree Calculus reducer, notes the time +and memory cost, links native Haskell tools for larger builds, and directs users +to **Advanced** for optional memory, work and time limits. It makes no numerical +browser/native performance claim. An elapsed indicator and Stop remain available; source is retained after cancellation or failure. No optional execution limits are enabled by default. Compilation speed depends on the source and pinned compiler; the notice does not prescribe an expected duration. @@ -124,11 +143,11 @@ or replace it. Keep important programs elsewhere too. Nothing is uploaded. ### Arguments, results, and IO -Choose an export and enter ordered arguments in separate literal fields. Each +Choose a definition and enter ordered inputs in separate literal fields. Each field is **one argument**: `42`, `"hello"`, `[1 2 3]`, `t`, or `(t t t)`. Add/Remove controls set the argument count. No fields means no application; -`[]` in a field means one empty-list argument. Result interpretation remains -explicit; suggested pure/browser-IO mode can be changed under **Advanced**. +`[]` in a field means one empty-list argument. Suggested result interpretation and pure/browser-IO mode can both be changed +under **Advanced**. Export metadata does not declare arity/types; no signature inference occurs. The editor accepts a data-only subset of tricu syntax: nonnegative decimal @@ -140,7 +159,7 @@ written as `t` / `(t t)`; byte lists use ordinary natural list syntax. Suggested bytes/Boolean inputs retain their original wire tags until edited, while edited literals produce the same canonical trees. -**Input help and raw JSON** exposes the alternative typed JSON array editor for +**Advanced → 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 @@ -224,7 +243,7 @@ These output limits remain in force with optional execution limits off. ## Compile source The unified **Playground** downloads the authenticated reference compiler bundle -only after an explicit Compile or Compile & run action. It invokes +only after an explicit **Compile draft** action. It invokes `source_unit.compileStableModuleDefinitions` with an explicit environment and a standalone source unit. There are no workspace imports or publication operations. The packaged compiler uses stable lowering. diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -1,3 +1,4 @@ +import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; import { createTreeExport } from './export-app.js'; import { activePane } from './tabs.js'; @@ -18,6 +19,7 @@ function el(id) { } const sourceEditor = el('source-editor'), compileButton = el('compile'); const restoreButton = el('restore-source'), downloadButton = el('download-bundle'); +let editingSource = false; let draft = false, scratch = false, compiled = false, busy = false, originalSource; let compilationStats = '', preferredName = 'id'; let compilerTimer; @@ -63,17 +65,24 @@ function controls(working) { programPicker.disabled = busy || entries.length === 0; versionPicker.disabled = busy; hashInput.disabled = busy; - el('load-release').disabled = busy || !programPicker.value.startsWith('package:'); + el('load-release').disabled = busy || !programPackage(programPicker.value); 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.disabled = busy || (draft ? !compiled : !loaded); + runButton.textContent = draft ? 'Run draft' : 'Run published'; + el('draft-run-note').hidden = !draft || compiled; + argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); + el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; runButton.classList.toggle('draft', draft); - compileButton.disabled = busy || !draft; + runButton.classList.toggle('primary', !draft || compiled); + compileButton.classList.toggle('primary', !compiled); + compileButton.disabled = busy || !draft || compiled; compileButton.hidden = !draft; restoreButton.hidden = !draft; sourceEditor.disabled = busy; + sourceEditor.readOnly = !editingSource && !draft; + el('edit-source').disabled = busy; + el('edit-source').hidden = originalSource === undefined || editingSource || draft; restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || !loaded; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; @@ -88,6 +97,8 @@ function controls(working) { for (const button of document.querySelectorAll('[data-compiler-example]')) button.disabled = busy; stopButton.disabled = !busy && !(draft && worker); + el('stop-compile').disabled = stopButton.disabled; + el('stop-compile').hidden = !draft; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); @@ -112,6 +123,7 @@ function clearOutput() { el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = ''; + el('result-inspection').hidden = true; el('graph-result').textContent = ''; el('graph-result').hidden = true; el('trace').replaceChildren(); @@ -137,6 +149,7 @@ function finish(message, error = false) { } function setPreset(entry, name = entry.export) { const preset = entry.presets?.[name] ?? entry; + argumentsUI.labels(demoInputLabels(entry.id, name)); argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = preset.output; @@ -290,6 +303,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { case 'done': { const { result, stats } = data; if (result.type === 'tree' || result.type === 'list') { + el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(result.value, null, 2); finish(`Returned ${result.type}: tree graph · ${stats.nodes.toLocaleString()} heap nodes · ${stats.work.toLocaleString()} work units`); @@ -314,6 +328,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { send({ type: 'load', ...selection, ...settings }); } function select(entry, release) { + editingSource = false; draft = false; scratch = false; originalSource = undefined; @@ -323,18 +338,21 @@ function select(entry, release) { clearTreeExport(); selected = entry; loaded = false; + el('source-section').open = true; + el('try-program').open = true; selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash; sourceEditor.hidden = true; el('source-notes').hidden = false; const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id); - chooseProgram(release ? `package:${release.name}` : `demo:${entry.id}`, release?.version); + chooseProgram(`demo:${entry.id}`, pinned?.version); showRelease(pinned); setPreset(entry); el('source').textContent = 'Verifying package…'; newWorker(false); } function invalidateProgram() { + editingSource = false; draft = false; scratch = false; originalSource = undefined; @@ -358,8 +376,10 @@ function invalidateProgram() { } function loadBundle(release) { const value = release?.hash ?? hashInput.value; + el('source-section').open = true; + el('try-program').open = true; invalidateProgram(); - chooseProgram(release ? `package:${release.name}` : 'hash', release?.version); + chooseProgram(release ? releaseProgram(release) : 'hash', release?.version); hashInput.value = value; try { const hash = bundleHash(value); @@ -382,10 +402,7 @@ exportsInput.onchange = () => { preferredName = exportsInput.value; clearTreeExp setPreset(selected, exportsInput.value); }; runButton.onclick = () => { if (draft) { - if (compiled) - runDraft(); - else - compileDraft(true); + runDraft(); return; } if (!loaded || !selection) @@ -436,6 +453,8 @@ inputForm.onsubmit = event => { nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; stopButton.onclick = () => finish('Stopped. Session discarded.', true); +el('stop-compile').onclick = stopButton.onclick; +el('try-program-link').onclick = () => { el('try-program').open = true; }; el('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) @@ -453,10 +472,24 @@ shareButton.onclick = async () => { } }; window.addEventListener('pagehide', dispose); +// Associate an example with its published name only through the catalogue's exact pin. +function examplePackage(entry) { + return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; +} +function programPackage(value) { + if (value.startsWith('package:')) + return value.slice(8); + const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined; + return entry ? examplePackage(entry) : undefined; +} +function releaseProgram(release) { + const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name); + return entry ? `demo:${entry.id}` : `package:${release.name}`; +} function chooseProgram(value, version) { programPicker.value = value; - const name = value.startsWith('package:') ? value.slice(8) : undefined; - el('program-release').hidden = name === undefined; + const name = programPackage(value); + el('program-release').hidden = name === undefined || (value.startsWith('demo:') && releases.filter(r => r.name === name).length < 2); el('program-hash').hidden = value !== 'hash'; if (name && versionPackage !== name) { versionPackage = name; @@ -470,18 +503,6 @@ function chooseProgram(value, version) { } 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(); @@ -500,7 +521,8 @@ function populatePrograms() { } const packages = document.createElement('optgroup'); packages.label = 'Published packages'; - for (const name of new Set(releases.map(r => r.name))) { + const exampleNames = new Set(entries.map(examplePackage)); + for (const name of new Set(releases.map(r => r.name).filter(name => !exampleNames.has(name)))) { const option = document.createElement('option'); option.value = `package:${name}`; option.textContent = name; @@ -533,7 +555,7 @@ programPicker.onchange = () => { }; versionPicker.onchange = invalidateProgram; el('load-release').onclick = () => { - const name = programPicker.value.startsWith('package:') ? programPicker.value.slice(8) : undefined; + const name = programPackage(programPicker.value); const release = releases.find(r => r.name === name && r.version === versionPicker.value); if (!release) return; @@ -603,7 +625,9 @@ el('recover-draft').onclick = () => { dispose(); clearOutput(); clearTreeExport(); - chooseProgram(entry ? `demo:${entry.id}` : ''); + chooseProgram(entry ? `demo:${entry.id}` : '', entry ? releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.version : undefined); + editingSource = true; + el('source-section').open = true; draft = true; scratch = !saved.demo; selected = entry; @@ -661,7 +685,16 @@ el('forget-draft').onclick = () => { } }; recoveryButtons(); +el('edit-source').onclick = () => { + editingSource = true; + el('source-section').open = true; + controls(false); + sourceEditor.focus(); +}; +mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; }; sourceEditor.oninput = () => { + editingSource = true; + el('source-section').open = true; preferredName = exportsInput.value || preferredName; dispose(); clearOutput(); @@ -683,9 +716,11 @@ restoreButton.onclick = () => { return; sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); + editingSource = false; + controls(busy); }; -compileButton.onclick = () => compileDraft(false); -function compileDraft(runAfterCompile) { +compileButton.onclick = () => compileDraft(); +function compileDraft() { if (!draft) return; preferredName = exportsInput.value || preferredName; @@ -728,8 +763,6 @@ function compileDraft(runAfterCompile) { el('status').textContent = 'Compiled'; compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; el('compiler-stats').textContent = compilationStats; - if (runAfterCompile) - runDraft(); break; case 'state': if (data.state === 'running') { @@ -763,6 +796,7 @@ function compileDraft(runAfterCompile) { controls(false); el('status').textContent = 'Finished'; if (data.result.type === 'tree' || data.result.type === 'list') { + el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; @@ -816,6 +850,8 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) if (!example) return; chooseProgram(''); + editingSource = true; + el('source-section').open = true; dispose(); clearOutput(); clearTreeExport(); diff --git a/ext/demo-site/dist/arguments-ui.js b/ext/demo-site/dist/arguments-ui.js @@ -5,13 +5,14 @@ export function argumentEditor(raw) { const add = document.getElementById('add-argument'); const rawMode = document.getElementById('raw-arguments'); let snapshot = '', disabled = false; + let labels = []; 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.'; + hint.textContent = 'No inputs supplied. Add an input to apply this definition.'; container.append(hint); } rows.forEach((row, i) => { @@ -19,7 +20,7 @@ export function argumentEditor(raw) { wrapper.className = 'argument-row'; const label = document.createElement('label'); label.htmlFor = `argument-${i}`; - label.textContent = `Argument ${i + 1}`; + label.textContent = labels[i] ?? `Argument ${i + 1}`; row.input.id = label.htmlFor; row.input.rows = 2; row.input.maxLength = 16000; @@ -91,6 +92,7 @@ export function argumentEditor(raw) { refresh(); return { refresh, + labels(value) { labels = value; }, disable(value) { disabled = value; refresh(); }, read() { if (raw.value.length > 16000) diff --git a/ext/demo-site/dist/demo-inputs.js b/ext/demo-site/dist/demo-inputs.js @@ -0,0 +1,14 @@ +// Curated presentation metadata, never signatures inferred from arbitrary bundles. +// The argument order comes from the published demos beside this site. +export function demoInputLabels(id, definition) { + switch (id) { + case 'intro': return definition === 'first' ? ['First value', 'Second value'] : definition === 'identity' ? ['Value'] : []; + case 'microscope': return definition === 'same' ? ['First tree', 'Second tree'] : ['Tree to inspect']; + case 'rgb': return ['RGB channels (0–255)']; + case 'shipping': return ['Destination', 'Cart subtotal (cents)', 'Package weight (grams)']; + case 'events': return definition === 'vendorB' ? ['Event [customer refunded totalCents]'] : ['Event [eventName customer totalCents]']; + case 'registration': return ['Name', 'Email', 'Age', 'Terms accepted (t = false, (t t) = true)']; + case 'migration': return ['Profile [name email]']; + default: return []; + } +} diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -29,34 +29,43 @@ <div class="workspace-panels"> <section id="playground" role="tabpanel" aria-labelledby="tab-playground" tabindex="0" aria-hidden="true" inert class="workspace-panel"> <div class="section-heading"><div><p class="eyebrow">Real packages, local execution</p><h2>The tree playground.</h2></div></div> - <p class="muted">Choose a program, change its arguments, and run it locally. Edit the source to make your own draft.</p> + <p class="muted">Choose a program, try its inputs, and run it locally. Read the source, or edit and explicitly compile your own draft.</p> <div data-package-slot><div id="package-selection"> - <div class="program-choice"><label for="program-picker">Program</label><select id="program-picker" aria-describedby="program-selection-status" disabled><option value="">Loading programs…</option></select></div> + <div class="program-choice"><label for="program-picker">Program</label><select id="program-picker" disabled><option value="">Loading programs…</option></select></div> <div id="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> + <section class="bundle-details" aria-label="Bundle details"><p id="release-info" class="hint">No named release selected.</p><p id="package-info" class="hint">Loading program catalogue…</p></section> </div></div> - <div class="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>Compilation runs locally and can use extra memory. Open Advanced below Run to set optional memory, work and time limits. Stop cancels compilation; your source stays in the editor.</p><p id="compile-activity" class="hint" role="status" hidden></p> + <div class="program-workspace"> + <details id="source-section" class="source-disclosure" open><summary>Source and editing</summary> + <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Read and edit source</h3><span id="source-label">tricu programs</span><button id="edit-source" type="button" disabled>Edit source</button></div> + <p id="compiler-disclosure" class="hint" hidden>Compilation uses a JavaScript Tree Calculus reducer and can take time or extra memory. Use the <a href="https://git.eversole.co/James/tricu/file/docs/build-and-run.md.html">native Haskell tools</a> for larger builds. Set optional limits under Advanced; Stop cancels compilation and keeps your source.</p> <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> + <textarea id="source-editor" rows="16" maxlength="32768" spellcheck="false" readonly hidden></textarea> <pre id="source-notes"><code id="source">Loading…</code></pre> - <div class="controls"><button id="compile" type="button" disabled>Compile draft only</button><button id="restore-source" type="button" disabled>Restore published source</button></div> + <div class="controls"><button id="compile" type="button" class="primary" disabled hidden>Compile draft</button><button id="stop-compile" type="button" disabled hidden>Stop</button><a id="try-program-link" href="#program-actions">Try this program ↓</a><button id="restore-source" type="button" disabled>Restore published source</button></div> + <p id="compile-activity" class="hint" role="status" hidden></p> <details class="scratch-examples"><summary>New scratch program · contract helpers included</summary><p class="hint">The compiler is just a tree too. Choose a standalone example, or edit a supplied demo using its pinned environment. No general module resolution.</p><div class="demo-picker" aria-label="Compiler source examples"><button type="button" data-compiler-example="identity">Identity</button><button type="button" data-compiler-example="contracts">Checked identity</button><button type="button" data-compiler-example="pair">Build a pair</button><button type="button" data-compiler-example="definitions">Two definitions</button><button type="button" data-compiler-example="input">Line input</button></div></details> <details class="compiler-metrics"><summary>Compilation statistics</summary><p id="compiler-stats" class="hint">Compilation starts only when requested.</p></details> </section> + </details> + <details id="try-program" class="execution-disclosure" open><summary><span class="execution-summary"><span id="execution-title">Try this program</span><span id="status" role="status">Loading</span></span></summary> <section class="execution-panel" aria-labelledby="execution-title"> - <div class="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> - <div class="argument-heading"><h3>Arguments</h3><button id="add-argument" type="button">Add argument</button></div> + <label for="export">Definition</label><select id="export" disabled></select> + <div class="argument-heading"><h3>Inputs</h3><button id="add-argument" type="button">Add input</button></div> <div id="argument-fields"></div> + </div> + <div id="program-actions" class="controls"><button type="button" id="run" class="primary" disabled>Run published</button><button type="button" id="stop" disabled>Stop</button></div> + <p id="draft-run-note" class="hint" role="status" hidden>Draft not compiled. Compile draft, then run it; published programs are not used as a fallback.</p> + <pre id="terminal" aria-label="Program output" tabindex="0"></pre> + <form id="input-form" hidden><label for="line-input">The program is waiting for one line (up to 256 characters)</label><div class="input-row"><input id="line-input" maxlength="512" autocomplete="off"><button type="submit">Send ↵</button></div></form> + <p id="result" role="status"></p><details id="result-inspection" hidden><summary>Inspect result tree</summary><pre id="graph-result" hidden aria-label="Bounded result graph"></pre></details> + <details class="run-tools"><summary>Advanced</summary> <details class="argument-help"><summary>Input help and raw JSON</summary> <p class="hint">One literal per field: <code>42</code>, <code>"hello"</code>, <code>[1 2 3]</code>, or <code>(t t t)</code>. List items use spaces. Names and arbitrary expressions are not evaluated. Remove all fields for no arguments; <code>[]</code> in a field is one empty-list argument.</p> <label><input id="raw-arguments" type="checkbox"> Edit raw typed JSON instead</label> @@ -64,12 +73,7 @@ <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></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> + <div><label for="mode">Execution mode</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div><div class="controls"><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy published selection link</button><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">Optional resource limits</summary><div class="limits-content"><div id="package-resource-choice" class="resource-choice"> <p>Optional limits: <strong id="package-resource-mode">Off</strong></p> <label><input id="uncapped" type="checkbox" checked aria-describedby="package-resource-warning"> Run without optional limits</label> @@ -82,9 +86,13 @@ </div><h3>Session settings</h3><div id="compiler-limits-defaults"></div><p class="hint">Memory is measured in live tree nodes, not megabytes. Garbage collection stays active with or without these limits. Bundle validation and IO format checks always apply.</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. Enabled limits apply to live nodes and evaluation frames; statistics remain cumulative.</p></div></details> </details> <p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p> + <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> + </details> </div> - <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> diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,3 +1,4 @@ +import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; import { createTreeExport } from './export-app.js'; import { activePane } from './tabs.js'; @@ -17,6 +18,7 @@ function el<T extends HTMLElement = HTMLElement>(id: string): T { } const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile'); const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle'); +let editingSource = false; let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; let compilationStats = '', preferredName = 'id'; let compilerTimer: ReturnType<typeof setInterval> | undefined; @@ -57,15 +59,21 @@ 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-release').disabled = busy || !programPackage(programPicker.value); 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.disabled = busy || (draft ? !compiled : !loaded); + runButton.textContent = draft ? 'Run draft' : 'Run published'; + el('draft-run-note').hidden = !draft || compiled; + argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); + el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; runButton.classList.toggle('draft', draft); - compileButton.disabled = busy || !draft; compileButton.hidden = !draft; restoreButton.hidden = !draft; - sourceEditor.disabled = busy; + runButton.classList.toggle('primary', !draft || compiled); + compileButton.classList.toggle('primary', !compiled); + compileButton.disabled = busy || !draft || compiled; compileButton.hidden = !draft; restoreButton.hidden = !draft; + sourceEditor.disabled = busy; sourceEditor.readOnly = !editingSource && !draft; + el<HTMLButtonElement>('edit-source').disabled = busy; + el('edit-source').hidden = originalSource === undefined || editingSource || draft; restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || !loaded; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; @@ -79,6 +87,8 @@ 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); + el<HTMLButtonElement>('stop-compile').disabled = stopButton.disabled; + el('stop-compile').hidden = !draft; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); mode.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy; shareButton.disabled = busy || draft || !loaded; @@ -90,6 +100,7 @@ function dispose(): void { } function clearOutput(): void { el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = ''; + el('result-inspection').hidden = true; el('graph-result').textContent = ''; el('graph-result').hidden = true; el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = ''; } @@ -104,6 +115,7 @@ function finish(message: string, error = false): void { } function setPreset(entry: CatalogueEntry, name = entry.export): void { const preset = entry.presets?.[name] ?? entry; + argumentsUI.labels(demoInputLabels(entry.id, name)); argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = preset.output; mode.value = preset.mode; } function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: (message: ClientMessage) => void): void { @@ -185,7 +197,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa case 'done': { const { result, stats } = data; if (result.type === 'tree' || result.type === 'list') { - el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(result.value, null, 2); + el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(result.value, null, 2); finish(`Returned ${result.type}: tree graph · ${stats.nodes.toLocaleString()} heap nodes · ${stats.work.toLocaleString()} work units`); } else finish(`Returned ${result.type}: ${JSON.stringify(result.value)}`); break; @@ -197,17 +209,21 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa send({ type: 'load', ...selection, ...settings }); } function select(entry: CatalogueEntry, release?: Release): void { + editingSource = false; draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false; dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; + el<HTMLDetailsElement>('source-section').open = true; + el<HTMLDetailsElement>('try-program').open = true; selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash; sourceEditor.hidden = true; el('source-notes').hidden = false; const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id); - chooseProgram(release ? `package:${release.name}` : `demo:${entry.id}`, release?.version); + chooseProgram(`demo:${entry.id}`, pinned?.version); showRelease(pinned); setPreset(entry); el('source').textContent = 'Verifying package…'; newWorker(false); } function invalidateProgram(): void { + editingSource = false; draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false; dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; exportsInput.replaceChildren(); treeExport.selection(exportsInput); @@ -218,8 +234,10 @@ function invalidateProgram(): void { } function loadBundle(release?: Release): void { const value = release?.hash ?? hashInput.value; + el<HTMLDetailsElement>('source-section').open = true; + el<HTMLDetailsElement>('try-program').open = true; invalidateProgram(); - chooseProgram(release ? `package:${release.name}` : 'hash', release?.version); + chooseProgram(release ? releaseProgram(release) : 'hash', release?.version); hashInput.value = value; try { const hash = bundleHash(value); selection = { hash }; @@ -231,7 +249,7 @@ el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault() 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 (draft) { runDraft(); return; } if (!loaded || !selection) return; clearOutput(); try { @@ -253,6 +271,8 @@ inputForm.onsubmit = event => { }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; stopButton.onclick = () => finish('Stopped. Session discarded.', true); +el<HTMLButtonElement>('stop-compile').onclick = stopButton.onclick; +el('try-program-link').onclick = () => { el<HTMLDetailsElement>('try-program').open = true; }; el<HTMLButtonElement>('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; @@ -263,10 +283,23 @@ shareButton.onclick = async () => { catch { el('result').textContent = url.href; } }; window.addEventListener('pagehide', dispose); +// Associate an example with its published name only through the catalogue's exact pin. +function examplePackage(entry: CatalogueEntry): string | undefined { + return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; +} +function programPackage(value: string): string | undefined { + if (value.startsWith('package:')) return value.slice(8); + const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined; + return entry ? examplePackage(entry) : undefined; +} +function releaseProgram(release: Release): string { + const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name); + return entry ? `demo:${entry.id}` : `package:${release.name}`; +} 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; + const name = programPackage(value); + el('program-release').hidden = name === undefined || (value.startsWith('demo:') && releases.filter(r => r.name === name).length < 2); el('program-hash').hidden = value !== 'hash'; if (name && versionPackage !== name) { versionPackage = name; @@ -276,18 +309,6 @@ function chooseProgram(value: string, version?: string): void { } } 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(); @@ -297,7 +318,8 @@ function populatePrograms(): void { 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 exampleNames = new Set(entries.map(examplePackage)); + for (const name of new Set(releases.map(r => r.name).filter(name => !exampleNames.has(name)))) { const option = document.createElement('option'); option.value = `package:${name}`; option.textContent = name; packages.append(option); } const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…'; @@ -314,7 +336,7 @@ programPicker.onchange = () => { }; versionPicker.onchange = invalidateProgram; el<HTMLButtonElement>('load-release').onclick = () => { - const name = programPicker.value.startsWith('package:') ? programPicker.value.slice(8) : undefined; + const name = programPackage(programPicker.value); 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, release); @@ -357,7 +379,8 @@ 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}` : ''); + chooseProgram(entry ? `demo:${entry.id}` : '', entry ? releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.version : undefined); + editingSource = true; el<HTMLDetailsElement>('source-section').open = true; 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.'; @@ -382,7 +405,12 @@ el<HTMLButtonElement>('forget-draft').onclick = () => { catch { el('draft-save-status').textContent = 'Unable to remove saved draft from local storage.'; } }; recoveryButtons(); +el<HTMLButtonElement>('edit-source').onclick = () => { + editingSource = true; el<HTMLDetailsElement>('source-section').open = true; controls(false); sourceEditor.focus(); +}; +mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; }; sourceEditor.oninput = () => { + editingSource = true; el<HTMLDetailsElement>('source-section').open = true; preferredName = exportsInput.value || preferredName; dispose(); clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); draft = scratch || sourceEditor.value !== originalSource; @@ -393,10 +421,10 @@ sourceEditor.oninput = () => { }; restoreButton.onclick = () => { if (originalSource === undefined) return; - sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); + sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); editingSource = false; controls(busy); }; -compileButton.onclick = () => compileDraft(false); -function compileDraft(runAfterCompile: boolean): void { +compileButton.onclick = () => compileDraft(); +function compileDraft(): void { if (!draft) return; preferredName = exportsInput.value || preferredName; dispose(); clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); @@ -420,7 +448,6 @@ function compileDraft(runAfterCompile: boolean): void { controls(false); el('status').textContent = 'Compiled'; compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; el('compiler-stats').textContent = compilationStats; - if (runAfterCompile) runDraft(); break; case 'state': if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = compiled ? 'Running' : 'Compiling'; } @@ -432,7 +459,7 @@ function compileDraft(runAfterCompile: boolean): void { case 'output': el('terminal').textContent += data.text; break; case 'done': pauseClock(); pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); el('status').textContent = 'Finished'; - if (data.result.type === 'tree' || data.result.type === 'list') { el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } + if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; el('compiler-stats').textContent = `${compilationStats} · ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; break; @@ -462,6 +489,7 @@ const examples = { for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => { const example = examples[button.dataset['compilerExample'] as keyof typeof examples]; if (!example) return; chooseProgram(''); + editingSource = true; el<HTMLDetailsElement>('source-section').open = true; 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; diff --git a/ext/demo-site/src/arguments-ui.ts b/ext/demo-site/src/arguments-ui.ts @@ -6,13 +6,14 @@ export function argumentEditor(raw: HTMLTextAreaElement) { const add = document.getElementById('add-argument') as HTMLButtonElement; const rawMode = document.getElementById('raw-arguments') as HTMLInputElement; let snapshot = '', disabled = false; + let labels: readonly string[] = []; 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); } + if (!rows.length) { const hint = document.createElement('p'); hint.className = 'hint'; hint.textContent = 'No inputs supplied. Add an input to apply this definition.'; 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}`; + const label = document.createElement('label'); label.htmlFor = `argument-${i}`; label.textContent = labels[i] ?? `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}`); @@ -56,6 +57,7 @@ export function argumentEditor(raw: HTMLTextAreaElement) { refresh(); return { refresh, + labels(value: readonly string[]) { labels = value; }, disable(value: boolean) { disabled = value; refresh(); }, read(): Literal[] { if (raw.value.length > 16000) throw new Error('Argument text limit exceeded'); diff --git a/ext/demo-site/src/demo-inputs.ts b/ext/demo-site/src/demo-inputs.ts @@ -0,0 +1,14 @@ +// Curated presentation metadata, never signatures inferred from arbitrary bundles. +// The argument order comes from the published demos beside this site. +export function demoInputLabels(id: string, definition: string): readonly string[] { + switch (id) { + case 'intro': return definition === 'first' ? ['First value', 'Second value'] : definition === 'identity' ? ['Value'] : []; + case 'microscope': return definition === 'same' ? ['First tree', 'Second tree'] : ['Tree to inspect']; + case 'rgb': return ['RGB channels (0–255)']; + case 'shipping': return ['Destination', 'Cart subtotal (cents)', 'Package weight (grams)']; + case 'events': return definition === 'vendorB' ? ['Event [customer refunded totalCents]'] : ['Event [eventName customer totalCents]']; + case 'registration': return ['Name', 'Email', 'Age', 'Terms accepted (t = false, (t t) = true)']; + case 'migration': return ['Profile [name email]']; + default: return []; + } +} diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -42,12 +42,6 @@ h3 { font-size:15px; font-weight:650; margin:0; } .source-panel .panel-heading span { color:#b1c1b7; } pre { font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; } .source-panel pre { padding:8px 20px 20px; margin:0; overflow:auto; max-height:480px; tab-size:2; } -/* Let the execution column set the desktop row height; source scrolls within it. */ -@media (min-width:741px) { - #playground .source-panel { display:flex; flex-direction:column; min-height:0; } - #playground .source-panel > .panel-heading { flex:none; } - #playground .source-panel > pre { flex:1 1 0; height:0; min-height:0; max-height:none; } -} .execution-panel { min-width:0; } .execution-panel > :not(.panel-heading) { margin-left:20px; margin-right:20px; } #terminal { white-space:pre-wrap; overflow-wrap:anywhere; min-height:145px; max-height:250px; overflow:auto; background:#f1f3eb; border:1px solid var(--line); padding:14px; border-radius:5px; } @@ -202,9 +196,8 @@ button.primary.draft { background:#8b581b; } .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 { margin:4px 0; font-size:12px; } .bundle-details .hint { overflow-wrap:anywhere; } .argument-heading { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-top:18px; } .argument-heading button,.argument-row button { padding:7px 10px; font-size:12px; } @@ -228,3 +221,34 @@ button.primary.draft { background:#8b581b; } .result-codec { margin:14px 0; } .result-codec label,.run-tools > div > label { display:block; margin-bottom:6px; } .run-tools > div { margin-top:12px; } + +/* Stacked source and execution share the hero's outer measure via .wrap. */ +.program-workspace .execution-panel { border:0; border-radius:0; padding-bottom:16px; background:var(--panel); } +.program-workspace .application-fields > select { max-width:440px; } +#argument-fields { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:0 18px; } +#argument-fields > .hint { grid-column:1 / -1; } +#program-actions { margin-bottom:14px; } +#draft-run-note { color:#8b581b; } +#compile-activity { margin:12px 20px; color:#e2d5af; } +#result-inspection { margin-bottom:16px; } +.source-disclosure { margin-top:24px; border:1px solid var(--line); border-radius:10px; overflow:hidden; } +.source-disclosure > summary { padding:16px 20px; font-size:15px; font-weight:650; } +.source-disclosure .source-panel { border-radius:0; } +#source-editor { flex:none; height:480px; min-height:320px; max-height:none; font-size:14px; } +#source-editor[readonly] { cursor:text; } +#edit-source { margin-left:auto; } +.compiler-source .controls a { color:#c6dacb; align-self:center; } +.compiler-source #compiler-disclosure a { color:inherit; } +@media(max-width:740px) { + #argument-fields { grid-template-columns:minmax(0,1fr); } + #source-editor { font-size:16px; height:320px; min-height:240px; max-height:360px; } + .source-disclosure > summary { padding:14px 16px; } +} +#result:empty { display:none; } + +.execution-disclosure { margin-top:24px; border:1px solid var(--line); border-radius:10px; background:var(--panel); overflow:hidden; } +.execution-disclosure > summary { padding:16px 20px; font-size:15px; font-weight:650; } +.execution-summary { display:inline-flex; width:calc(100% - 24px); align-items:center; justify-content:space-between; gap:12px; } +.execution-summary #status { font-size:12px; font-weight:400; color:var(--muted); } +.execution-panel .trace-panel { margin:18px 20px; } +@media(max-width:740px) { .execution-disclosure > summary { padding:14px 16px; } } diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs @@ -173,13 +173,34 @@ try { assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); assert.deepEqual(await evaluate("[...document.querySelectorAll('#program-picker optgroup')].map(g=>g.label)"),['Examples','Published packages']); assert.equal(await evaluate("document.querySelector('#program-picker option[value=hash]').textContent"),'Load by bundle hash…'); + assert.deepEqual(await evaluate("Array.from([...document.getElementById('program-picker').querySelectorAll('optgroup')].find(g=>g.label==='Published packages').querySelectorAll('option'),o=>o.value)"),packageIndex.releases.filter(r=>!r.demo).map(r=>'package:'+r.name).filter((v,i,a)=>a.indexOf(v)===i)); + assert.equal(await evaluate("document.getElementById('program-release').hidden"),true,'single-release example needs no version controls'); + await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); + assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); + assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),['0.1.0','0.1.1']); + assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.1'); + await evaluate("document.getElementById('package-version').value='0.1.0';document.getElementById('package-version').dispatchEvent(new Event('change'))"); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); + await click('tab-export-definition'); + assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:rgb'); + assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.0'); + await click('load-release');await wait(status('Ready')); + assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:rgb'); + assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(packageIndex.releases.find(r=>r.name==='rgb'&&r.version==='0.1.0').hash)); + assert.match(await evaluate("document.getElementById('release-info').textContent"),/rgb@0.1.0/); + await click('tab-playground'); + assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'older release does not borrow current editable source'); + await evaluate("document.getElementById('package-version').value='0.1.1';document.getElementById('package-version').dispatchEvent(new Event('change'))"); + await click('load-release');await wait(status('Ready')); + assert.equal(await evaluate("document.getElementById('source-editor').hidden"),false,'current release retains curated source and presets'); const beforeChoice=requests.filter(p=>p.startsWith('/_arboricx/bundle/hash/')).length; await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); assert.equal(await evaluate("document.getElementById('program-hash').hidden"),true); assert.equal(await evaluate("document.getElementById('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))]) { + assert.match(await evaluate("document.getElementById('package-info').textContent"),/No bundle loaded/); + for(const name of [...new Set(packageIndex.releases.filter(r=>!r.demo).map(r=>r.name))]) { await evaluate(`document.getElementById('program-picker').value=${JSON.stringify('package:'+name)};document.getElementById('program-picker').dispatchEvent(new Event('change'))`); assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),packageIndex.releases.filter(r=>r.name===name).map(r=>r.version),`versions belong to ${name}`); } @@ -275,10 +296,24 @@ try { const assertSourceFill=async()=>{ const layout=await evaluate("(()=>{const panel=document.querySelector('#playground .source-panel'),source=panel.querySelector('#source-editor:not([hidden]),#source-notes:not([hidden])');const p=panel.getBoundingClientRect(),s=source.getBoundingClientRect();return {left:s.left,right:s.right,pLeft:p.left,pRight:p.right,height:s.height};})()"); assert.ok(layout.left>=layout.pLeft-1 && layout.right<=layout.pRight+1,'source stays within its panel'); - assert.ok(layout.height>0,'source is visible'); return layout.height; + assert.ok(layout.height>0,'source is visible'); + const widths=await evaluate("({source:document.querySelector('#playground .source-panel').getBoundingClientRect().width,execution:document.querySelector('#playground .execution-panel').getBoundingClientRect().width})"); + assert.ok(Math.abs(widths.source-widths.execution)<4,'source uses the full program width'); return layout.height; }; + assert.equal(await evaluate("document.getElementById('source-section').open"),true); + assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),true); await assertSourceFill(); - console.log('PASS editable source stays within shared workspace'); + assert.deepEqual(await evaluate("({ordered:document.getElementById('source-section').compareDocumentPosition(document.getElementById('try-program'))===Node.DOCUMENT_POSITION_FOLLOWING,traceInside:!!document.querySelector('#try-program .trace-panel'),compileInside:!!document.querySelector('#source-section #compile'),bundleVisible:!!document.querySelector('section.bundle-details'),statusRemoved:!document.getElementById('program-selection-status')})"),{ordered:true,traceInside:true,compileInside:true,bundleVisible:true,statusRemoved:true}); + await evaluate("document.querySelector('#source-section>summary').click();document.querySelector('#try-program>summary').click()"); + assert.equal(await evaluate("document.getElementById('source-section').open"),false); + assert.equal(await evaluate("document.getElementById('try-program').open"),false); + await click('tab-export-definition'); + assert.ok(await evaluate("document.getElementById('package-info').getBoundingClientRect().height>0"),'bundle details stay visible when source and Try are collapsed'); + await click('tab-playground'); + await evaluate("document.querySelector('#source-section>summary').click()"); + await click('try-program-link'); + assert.equal(await evaluate("document.getElementById('try-program').open"),true,'source footer reveals Try before jumping to Run'); + console.log('PASS source-first ordering / independent disclosures / visible provenance / nested trace / source-footer compile'); const resultRecord=async()=>{ const value=await evaluate("document.getElementById('result').textContent"); @@ -286,7 +321,9 @@ try { }; for(const [demo,exports] of [['shipping',['v1','v2']],['events',['vendorA','vendorB']],['registration',['main']],['migration',['v1','v2']]]){ await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); - await assertSourceFill(); + assert.equal(await evaluate("document.getElementById('source-section').open"),true,'every selected demo starts with readable source'); + await evaluate("document.getElementById('source-section').open=true");await assertSourceFill(); + if(demo==='shipping')assert.deepEqual(await evaluate("[...document.querySelectorAll('#argument-fields label')].map(l=>l.textContent)"),['Destination','Cart subtotal (cents)','Package weight (grams)']); const hash=catalogue.find(e=>e.id===demo).hash; assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(hash)); const results=[]; @@ -430,7 +467,7 @@ try { 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)); + assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp('SHA-256 verified.*'+hash)); }; await loadHash(published.append); assert.equal(await evaluate("document.getElementById('mode').value"),'pure'); @@ -512,20 +549,20 @@ try { 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/); + assert.match(await evaluate("document.getElementById('release-info').textContent"),/tricu-base@0.1.0/); await evaluate("document.getElementById('package-version').value='0.1.1';document.getElementById('package-version').dispatchEvent(new Event('change'))"); assert.equal(await evaluate("document.getElementById('run').disabled"),true); assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); await click('load-release');await wait(status('Ready')); - assert.match(await evaluate("document.getElementById('program-selection-status').textContent"),/Loaded: tricu-base@0.1.1/); + assert.match(await evaluate("document.getElementById('release-info').textContent"),/tricu-base@0.1.1/); console.log('PASS published library selection / verified native library running in Chromium / no fabricated editable source'); await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); // The same controls now serve published releases and explicitly local drafts. - await evaluate("document.getElementById('program-picker').value='package:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'"); + await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'"); await click('load-release');await wait(status('Ready')); assert.match(await evaluate("document.getElementById('release-info').textContent"),/intro@0.1.0/); - assert.equal(await evaluate("document.getElementById('program-picker').value"),'package:intro'); + assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); const publishedSource=await evaluate("document.getElementById('source-editor').value"); await evaluate(`window.__createURL=URL.createObjectURL;URL.createObjectURL=blob=>{window.__bundleBytes=blob.arrayBuffer().then(b=>Array.from(new Uint8Array(b)));return window.__createURL(blob);}; window.__bundleClick=e=>{if(e.target instanceof HTMLAnchorElement && e.target.download){e.preventDefault();window.__bundleName=e.target.download;}};document.addEventListener('click',window.__bundleClick);`); @@ -534,13 +571,22 @@ try { assert.equal(pin(downloaded),catalogue.find(e=>e.id==='intro').hash); assert.equal(await evaluate('window.__bundleName'),pin(downloaded)+'.arboricx'); await evaluate("URL.createObjectURL=window.__createURL;document.removeEventListener('click',window.__bundleClick)"); + await click('edit-source'); + assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),false); + assert.equal(await evaluate("document.getElementById('run').textContent"),'Run published','opening the editor alone does not replace the executable'); + const beforeDraftEdit=requests.filter(p=>p.endsWith('/compiler.json')).length; await evaluate("document.getElementById('source-editor').value=document.getElementById('source-editor').value.replace('first 42 7','first 43 7');document.getElementById('source-editor').dispatchEvent(new Event('input'))"); assert.match(await evaluate("document.getElementById('authorship').textContent"),/Local draft/); assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),true); assert.equal(await evaluate("document.getElementById('share').disabled"),true); assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); assert.equal(await evaluate("document.querySelector('dialog')"),null); - assert.match(await evaluate("document.getElementById('compiler-disclosure').textContent"),/Advanced below Run/); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + assert.equal(await evaluate("document.getElementById('compile').disabled"),false); + await evaluate("document.getElementById('run').onclick()"); + assert.equal(requests.filter(p=>p.endsWith('/compiler.json')).length,beforeDraftEdit,'Run cannot compile an uncompiled draft'); + assert.match(await evaluate("document.getElementById('compiler-disclosure').textContent"),/limits under Advanced/); + await click('compile');await wait(status('Compiled')+' || '+status('Stopped'),60); await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); console.log('PASS curated intro edit with explicit empty environment',await evaluate("document.getElementById('compiler-stats').textContent")); @@ -548,7 +594,8 @@ try { // executes the previously compiled 43 or the published 42. await evaluate("document.getElementById('source-editor').value='answer = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); assert.equal(await evaluate("document.getElementById('export').disabled"),true); - await click('run');await wait(status('Stopped')); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + await click('compile');await wait(status('Stopped')); assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic/); await click('restore-source');await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-editor').value"),publishedSource); @@ -559,6 +606,7 @@ try { for(const demo of ['shipping','events','registration','migration']) { await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); await evaluate("document.getElementById('source-editor').value='answer = stringField \"status\" \"edited\"';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]';document.getElementById('output-type').value='list'"); + await click('compile');await wait(status('Compiled')+' || '+status('Stopped'),60); await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); const result=await evaluate("document.getElementById('result').textContent"); assert.match(result,/^Returned list:/,demo+': '+result); @@ -679,7 +727,7 @@ try { await click('compile');await wait(compilerStatus('Stopped')); assert.match(await evaluate("document.getElementById('result').textContent"),expected); assert.equal(await evaluate("document.getElementById('export').disabled"),true); - assert.match(await evaluate("document.getElementById('run').textContent"),/Compile & run/); + assert.match(await evaluate("document.getElementById('run').textContent"),/Run draft/); } compilerFault=undefined; // Source bound is enforced before the compiler bundle is downloaded again. @@ -758,7 +806,9 @@ try { await click('compile'); await new Promise(resolve=>setTimeout(resolve,31_000)); assert.equal(await evaluate("document.getElementById('status').textContent"),'Compiling'); - await click('stop');await wait(compilerStatus('Stopped')); + await evaluate("document.getElementById('try-program').open=false"); + assert.equal(await evaluate("document.getElementById('stop-compile').disabled"),false); + await click('stop-compile');await wait(compilerStatus('Stopped')); assert.equal(await evaluate('globalThis.compilerWorkerTerminated'),true); console.log('PASS no compiler timeout past 30 seconds / manual CPU-bound cancellation'); await evaluate('globalThis.compilerWorkerTerminated=false');await click('compile');await click('tab-how'); @@ -805,6 +855,7 @@ try { })()`); assert.equal(layout.overflow,false,`overflow at ${width}px`); await click('tab-playground'); + await evaluate("document.getElementById('source-section').open=true"); if(width>740)await assertSourceFill(); else assert.ok(await evaluate("document.getElementById('source-editor').getBoundingClientRect().height<=360"),'stacked mobile source stays bounded'); assert.ok(Math.abs(layout.leafOffset-43.5/80)<0.01,`universal Leaf offset at ${width}px`); @@ -917,7 +968,7 @@ try { assert.equal(await evaluate("JSON.parse(localStorage.getItem('arboricx.local-draft.v1')).demo.environment"),'practical'); await reloadForRecovery(); await click('tab-playground');await wait(status('Ready')); - await click('recover-draft');await click('run');await wait(status('Finished')); + await click('recover-draft');await click('compile');await wait(status('Compiled'));await click('run');await wait(status('Finished')); assert.match(await evaluate("document.getElementById('result').textContent"),/^Returned list:/); await evaluate("const d=JSON.parse(localStorage.getItem('arboricx.local-draft.v1'));d.demo.hash='0'.repeat(64);localStorage.setItem('arboricx.local-draft.v1',JSON.stringify(d))"); const beforeMissingContext=requests.filter(path=>path.endsWith('/compiler.json')).length; diff --git a/ext/demo-site/test/demo-inputs.test.mjs b/ext/demo-site/test/demo-inputs.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { demoInputLabels } from '../dist/demo-inputs.js'; + +test('curated presentation labels match preset argument counts and known order',async()=>{ + const entries=JSON.parse(await readFile(new URL('../catalogue.json',import.meta.url))); + for(const entry of entries)for(const [name,preset] of Object.entries(entry.presets??{[entry.export]:entry})) + assert.equal(demoInputLabels(entry.id,name).length,preset.arguments.length,`${entry.id}:${name}`); + assert.deepEqual(demoInputLabels('shipping','v1'),['Destination','Cart subtotal (cents)','Package weight (grams)']); + assert.deepEqual(demoInputLabels('unknown','main'),[]); +}); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -106,17 +106,18 @@ test('unified authoring has inline compilation notice and retains published iden assert.doesNotMatch(html,/id="tab-compiler"/); for(const id of ['source-editor','authorship','restore-source','compiler-disclosure','download-bundle','program-picker','package-version'])assert.match(html,new RegExp(`id="${id}"`)); assert.doesNotMatch(html,/<dialog|compile-consent|can take minutes|Standard|tab crash/); - assert.match(html,/Compilation runs locally and can use extra memory/); - assert.match(html,/Advanced below Run/); + assert.match(html,/Compilation uses a JavaScript Tree Calculus reducer/); + assert.match(html,/limits under Advanced/); }); 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.match(html,/<section class="bundle-details" aria-label="Bundle details">/); + assert.doesNotMatch(html,/program-selection-status|Choose a program to load/); assert.doesNotMatch(html,/id="demo-picker"|id="package-name"|Published packages and provenance/); - for(const id of ['program-release','program-hash','program-selection-status'])assert.match(html,new RegExp(`id="${id}"`)); + for(const id of ['program-release','program-hash'])assert.match(html,new RegExp(`id="${id}"`)); assert.match(html,/Bundle hash \(SHA-256\)/); assert.match(html,/>Load bundle<\/button>/); const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<p class="hint"><a href="#export-definition"')); @@ -126,3 +127,27 @@ test('primary workspace is compact and guards-off defaults stay inside Advanced' assert.match(html,/One literal per field/); assert.match(html,/<details class="trace-panel">/); }); + +test('source-first workspace has full-width source and explicit compilation',async()=>{ + const html=await readFile(new URL('index.html',here),'utf8'); + const app=await readFile(new URL('src/app.ts',here),'utf8'); + const css=await readFile(new URL('style.css',here),'utf8'); + assert.ok(html.indexOf('id="source-section"')<html.indexOf('id="try-program"')); + assert.ok(html.indexOf('id="compile"')<html.indexOf('id="try-program"')); + assert.match(html,/<details id="source-section"[^>]* open>/); + assert.match(html,/<details id="try-program"[^>]* open>/); + assert.ok(html.indexOf('class="trace-panel"')>html.indexOf('id="try-program"')); + assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('id="export-definition"')); + assert.match(html,/<details id="source-section"/); + assert.match(html,/id="source-editor"[^>]*readonly/); + assert.match(html,/id="edit-source"/); + assert.match(html,/>Compile draft<\/button>/); + assert.doesNotMatch(app,/Compile & run|compileDraft\(true\)/); + assert.match(app,/draft \? !compiled : !loaded/); + assert.match(html,/id="draft-run-note"/); + assert.match(html,/<details id="result-inspection"/); + assert.match(html,/class="hero wrap"/); + assert.match(html,/class="workspace wrap"/); + assert.match(css,/\.wrap\s*\{[^}]*max-width:1120px;[^}]*padding-left:28px;[^}]*padding-right:28px;/); + assert.doesNotMatch(css,/\.workspace\s*\{[^}]*(?:max-width|width:|padding-inline|padding-left|padding-right)/); +});