tricu

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

commit bbc3e818b865c27b6f5c3f03e809d36b2d07597f
parent 2dc371aa8c81b65a2a87b987f2d1719e7873ba63
Author: James Eversole <james@eversole.co>
Date:   Wed, 14 Oct 2026 08:42:01 -0500

Bring bundle downloads to the forefront

Diffstat:
Mext/demo-site/README.md | 10+++++++---
Mext/demo-site/dist/app.js | 121+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mext/demo-site/index.html | 11++++++++---
Mext/demo-site/src/app.ts | 65++++++++++++++++++++++++++++++++++++++++++++---------------------
Mext/demo-site/style.css | 17+++++++++++++++++
Mext/demo-site/test/browser.mjs | 43+++++++++++++++++++++++++++++++++++++++----
Mext/demo-site/test/bundle.test.mjs | 6+++---
Mext/demo-site/test/packages.test.mjs | 6++++--
Mext/demo-site/test/site.test.mjs | 19+++++++++++++++++++
9 files changed, 217 insertions(+), 81 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -181,7 +181,7 @@ 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 +Single-release examples also show an active version selector. The Published packages group contains libraries and other non-demo packages, which reveal the version picker and **Load package**. Hash loading reveals **Bundle hash (SHA-256)** and **Load bundle**, accepting arbitrary browser-compatible bundles from this site's registry. @@ -231,8 +231,12 @@ 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. -**Download published .arboricx** returns the original, bounded, hash-verified bundle -bytes, not a re-encoding, applied result or local draft. Textual exports likewise +**Download .arboricx**, beside the primary **Load package** action in both +Playground and Export, returns the original, bounded, hash-verified bundle +bytes for the selected version or hash, not a re-encoding, applied result or local +draft. Download verifies the bundle independently; it does not require Load, +change the loaded executable, compile source or run a program. Narrow layouts +place the download button on its own full-width toolbar row. Textual exports likewise contain a selected stored definition. These actions and published sharing are disabled while authoring a draft; restore the published source first. Draft bundle publication/export is not part of this profile. diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -56,7 +56,6 @@ let loaded = false, worker, pending; let automaticRun = false; let savedRun; let preferredExport; -let downloadAfterLoad = false; const traceRows = new Map(); function pauseClock() { deadline.pause(); } function clock() { deadline.start(); } @@ -91,7 +90,7 @@ function controls(working) { ? 'Source is shown for reference only. This package does not support editing or local compilation.' : 'This is a compiled package. Editable source is not included; you can run or export its stored definitions.'; restoreButton.disabled = busy || originalSource === undefined || !draft; - downloadButton.disabled = busy || draft || !loaded; + downloadButton.disabled = busy || draft || downloadHash() === undefined; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; el('authorship').classList.toggle('draft', draft); sourceEditor.classList.toggle('draft', draft); @@ -150,6 +149,10 @@ function finish(message, error = false, reason = 'failure') { el('package-info').textContent = `Package not loaded. ${message}`; if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message); + if (operation === 'downloading') { + el('bundle-download-status').textContent = message; + el('bundle-download-status').classList.toggle('error', error); + } dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; el('result').textContent = message; @@ -210,24 +213,6 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { lineInput.focus(); } break; - case 'downloaded': { - if (data.hash !== selection?.hash || draft) - break; - pauseClock(); - operation = undefined; - controls(false); - el('status').textContent = 'Ready'; - const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = `${data.hash}.arboricx`; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - el('result').textContent = `Downloaded original verified bundle ${data.hash}. No draft or applied result included.`; - break; - } case 'loaded': { currentVerified = true; loaded = true; @@ -244,7 +229,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { preferredExport = undefined; treeExport.selection(exportsInput); el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`; - if (!preserveInputs && !automaticRun && !exportAfterLoad && !downloadAfterLoad) { + if (!preserveInputs && !automaticRun && !exportAfterLoad) { originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined; @@ -253,15 +238,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { sourceAvailable = data.source !== null; el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`; - if (downloadAfterLoad) { - downloadAfterLoad = false; - pauseClock(); - operation = 'downloading'; - deadline.reset(PACKAGE_ACTIVE_MS); - clock(); - send({ type: 'download' }); - } - else if (exportAfterLoad) { + if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); } @@ -354,7 +331,6 @@ function select(entry, release) { draft = false; scratch = false; originalSource = undefined; - downloadAfterLoad = false; dispose(); clearOutput(); clearTreeExport(); @@ -374,12 +350,12 @@ function select(entry, release) { newWorker(false); } function invalidateProgram() { + el('bundle-download-status').textContent = ''; editingSource = false; sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; - downloadAfterLoad = false; dispose(); clearOutput(); clearTreeExport(); @@ -512,9 +488,12 @@ function releaseProgram(release) { return entry ? `demo:${entry.id}` : `package:${release.name}`; } function chooseProgram(value, version) { + el('bundle-download-status').textContent = ''; programPicker.value = value; const name = programPackage(value); - el('program-release').hidden = name === undefined || (value.startsWith('demo:') && releases.filter(r => r.name === name).length < 2); + el('program-release').hidden = name === undefined; + el('package-toolbar').hidden = name === undefined && value !== 'hash'; + el('bundle-hash-help').hidden = value !== 'hash'; el('program-hash').hidden = value !== 'hash'; if (name && versionPackage !== name) { versionPackage = name; @@ -590,20 +569,72 @@ el('load-release').onclick = () => { else loadBundle(release); }; +function downloadHash() { + const name = programPackage(programPicker.value); + if (name) + return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash; + if (programPicker.value === 'hash') { + try { + return bundleHash(hashInput.value); + } + catch { + return undefined; + } + } + return undefined; +} downloadButton.onclick = () => { - if (draft || !loaded || !selection) + const hash = downloadHash(); + if (busy || draft || !hash) return; - if (worker) { - operation = 'downloading'; - controls(true); - deadline.reset(PACKAGE_ACTIVE_MS); - clock(); - worker.postMessage({ type: 'download' }); - } - else { - downloadAfterLoad = true; - newWorker(false, true); - } + // Download has its own verified target, not the loaded executable selection. + // It never changes source, definitions, arguments or the Run/Export target. + dispose(); + operation = 'downloading'; + const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); + worker = current; + controls(true); + el('status').textContent = 'Downloading bundle'; + el('bundle-download-status').textContent = 'Verifying published bundle…'; + el('bundle-download-status').classList.remove('error'); + deadline.reset(PACKAGE_ACTIVE_MS); + clock(); + current.onmessage = ({ data }) => { + if (worker !== current) + return; + if (data.type === 'loaded') { + if (data.hash !== hash) { + finish('Bundle download target mismatch', true); + return; + } + current.postMessage({ type: 'download' }); + } + else if (data.type === 'downloaded') { + if (data.hash !== hash) { + finish('Bundle download target mismatch', true); + return; + } + const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${hash}.arboricx`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`; + dispose(); + el('status').textContent = loaded ? 'Ready' : 'Load package'; + el('bundle-download-status').textContent = message; + el('result').textContent = message; + el('result').className = ''; + } + else if (data.type === 'error') + finish(data.message, true); + }; + current.onerror = event => { if (worker === current) + finish(event.message || 'Worker error', true); }; + current.postMessage({ type: 'load', hash, uncapped: true }); }; el('edit-source').onclick = () => { editingSource = true; diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -34,8 +34,13 @@ <div data-package-slot><div id="package-selection"> <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> + <div id="package-toolbar" class="package-toolbar" hidden> + <div id="program-release" class="release-choice" hidden><label for="package-version">Package version</label><div class="input-row"><select id="package-version" aria-label="Exact package version"></select><button id="load-release" type="button" class="primary">Load package</button></div></div> + <form id="program-hash" class="hash-choice" hidden><label for="bundle-hash">Bundle hash (SHA-256)</label><div class="input-row"><input id="bundle-hash" maxlength="64" spellcheck="false" autocomplete="off" aria-describedby="bundle-hash-help"><button id="load" type="submit" class="primary">Load bundle</button></div></form> + <button type="button" id="download-bundle" disabled>↓ Download .arboricx</button> + </div> + <p id="bundle-hash-help" class="hint" hidden>Paste the 64-character hash of an Arboricx bundle to load or download it from this site's registry.</p> + <p id="bundle-download-status" class="hint" role="status"></p> <section class="bundle-details" aria-label="Bundle details"><p id="release-info" class="hint">No named release selected.</p><p id="package-info" class="hint">Loading program catalogue…</p></section> </div></div> <div class="program-workspace"> @@ -73,7 +78,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><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> + <div><label for="mode">Execution mode</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div><div class="controls"><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy published selection link</button></div> <div class="step-controls"><label><input type="checkbox" id="step-mode"> Pause before each IO action</label><button type="button" id="next" disabled>Next action →</button></div> <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> diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -51,7 +51,6 @@ let loaded = false, worker: Worker | undefined, pending: { kind: 'input' | 'step let automaticRun = false; let savedRun: Extract<ClientMessage, { type: 'run' }> | undefined; let preferredExport: string | undefined; -let downloadAfterLoad = false; const traceRows = new Map<number, HTMLLIElement>(); function pauseClock(): void { deadline.pause(); } function clock(): void { deadline.start(); } @@ -82,7 +81,7 @@ function controls(working: boolean): void { ? 'Source is shown for reference only. This package does not support editing or local compilation.' : 'This is a compiled package. Editable source is not included; you can run or export its stored definitions.'; restoreButton.disabled = busy || originalSource === undefined || !draft; - downloadButton.disabled = busy || draft || !loaded; + downloadButton.disabled = busy || draft || downloadHash() === undefined; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; el('authorship').classList.toggle('draft', draft); sourceEditor.classList.toggle('draft', draft); @@ -117,6 +116,7 @@ function finish(message: string, error = false, reason: 'failure' | 'timeout' | if (error && reason === 'failure') message = operationFailure(operation, message); if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message); + if (operation === 'downloading') { el('bundle-download-status').textContent = message; el('bundle-download-status').classList.toggle('error', error); } dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; el('result').textContent = message; el('result').className = error ? 'error' : ''; for (const row of traceRows.values()) row.classList.remove('pending'); @@ -149,16 +149,6 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa if (data.state === 'input') lineInput.focus(); } break; - case 'downloaded': { - if (data.hash !== selection?.hash || draft) break; - pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; - const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); - const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`; - document.body.append(anchor); anchor.click(); anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - el('result').textContent = `Downloaded original verified bundle ${data.hash}. No draft or applied result included.`; - break; - } case 'loaded': { currentVerified = true; loaded = true; const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export); @@ -169,7 +159,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa if (oldExport && data.exports.some(e => e.name === oldExport)) exportsInput.value = oldExport; preferredExport = undefined; treeExport.selection(exportsInput); el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`; - if (!preserveInputs && !automaticRun && !exportAfterLoad && !downloadAfterLoad) { + if (!preserveInputs && !automaticRun && !exportAfterLoad) { originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined; el('source-notes').hidden = originalSource !== undefined; @@ -177,8 +167,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa sourceAvailable = data.source !== null; el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`; - if (downloadAfterLoad) { downloadAfterLoad = false; pauseClock(); operation = 'downloading'; deadline.reset(PACKAGE_ACTIVE_MS); clock(); send({ type: 'download' }); } - else if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); } + if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); } else if (automaticRun && savedRun) { pauseClock(); runClock(); automaticRun = false; send(savedRun); } else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; } break; @@ -219,7 +208,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa } function select(entry: CatalogueEntry, release?: Release): void { editingSource = false; sourceAvailable = false; - draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false; + draft = false; scratch = false; originalSource = undefined; dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; el<HTMLDetailsElement>('source-section').open = true; el<HTMLDetailsElement>('try-program').open = true; @@ -232,8 +221,9 @@ function select(entry: CatalogueEntry, release?: Release): void { newWorker(false); } function invalidateProgram(): void { + el('bundle-download-status').textContent = ''; editingSource = false; sourceAvailable = false; - draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false; + draft = false; scratch = false; originalSource = undefined; dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; exportsInput.replaceChildren(); treeExport.selection(exportsInput); sourceEditor.hidden = true; el('source-notes').hidden = false; @@ -306,9 +296,12 @@ function releaseProgram(release: Release): string { return entry ? `demo:${entry.id}` : `package:${release.name}`; } function chooseProgram(value: string, version?: string): void { + el('bundle-download-status').textContent = ''; programPicker.value = value; const name = programPackage(value); - el('program-release').hidden = name === undefined || (value.startsWith('demo:') && releases.filter(r => r.name === name).length < 2); + el('program-release').hidden = name === undefined; + el('package-toolbar').hidden = name === undefined && value !== 'hash'; + el('bundle-hash-help').hidden = value !== 'hash'; el('program-hash').hidden = value !== 'hash'; if (name && versionPackage !== name) { versionPackage = name; @@ -351,10 +344,40 @@ el<HTMLButtonElement>('load-release').onclick = () => { if (demo) select(demo, release); else loadBundle(release); }; +function downloadHash(): BundleHash | undefined { + const name = programPackage(programPicker.value); + if (name) return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash; + if (programPicker.value === 'hash') { + try { return bundleHash(hashInput.value); } catch { return undefined; } + } + return undefined; +} downloadButton.onclick = () => { - if (draft || !loaded || !selection) return; - if (worker) { operation = 'downloading'; controls(true); deadline.reset(PACKAGE_ACTIVE_MS); clock(); worker.postMessage({ type: 'download' }); } - else { downloadAfterLoad = true; newWorker(false, true); } + const hash = downloadHash(); if (busy || draft || !hash) return; + // Download has its own verified target, not the loaded executable selection. + // It never changes source, definitions, arguments or the Run/Export target. + dispose(); operation = 'downloading'; + const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker = current; + controls(true); el('status').textContent = 'Downloading bundle'; + el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error'); + deadline.reset(PACKAGE_ACTIVE_MS); clock(); + current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => { + if (worker !== current) return; + if (data.type === 'loaded') { + if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; } + current.postMessage({ type: 'download' }); + } else if (data.type === 'downloaded') { + if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; } + const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); + const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${hash}.arboricx`; + document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); + const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`; + dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; + el('bundle-download-status').textContent = message; el('result').textContent = message; el('result').className = ''; + } else if (data.type === 'error') finish(data.message, true); + }; + current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); }; + current.postMessage({ type: 'load', hash, uncapped: true }); }; el<HTMLButtonElement>('edit-source').onclick = () => { editingSource = true; el<HTMLDetailsElement>('source-section').open = true; controls(false); sourceEditor.focus(); diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -249,3 +249,20 @@ button.primary.draft { background:#8b581b; } .execution-summary #status { font-size:12px; font-weight:400; color:var(--muted); } .execution-panel .trace-panel { margin:18px 20px; } @media(max-width:740px) { .execution-disclosure > summary { padding:14px 16px; } } + +/* Keep version/load/download together and promote Load without hiding Download. */ +.package-toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; padding:16px; border:1px solid var(--line); border-radius:8px; background:var(--paper); } +.package-toolbar label { font-size:14px; font-weight:650; margin:0 0 6px; } +.package-toolbar .input-row { gap:10px; } +.package-toolbar button { min-height:44px; white-space:nowrap; } +.release-choice select { width:9rem; flex:none; } +.hash-choice { flex:1 1 320px; min-width:0; } +.hash-choice input { min-width:0; } +#download-bundle { font-weight:650; } +#bundle-download-status:empty { display:none; } +@media(max-width:740px) { + .package-toolbar { padding:12px; } + .release-choice,.hash-choice { width:100%; } + .release-choice select { flex:1; width:auto; } + #download-bundle { width:100%; } +} diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs @@ -175,7 +175,7 @@ try { 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'); + assert.equal(await evaluate("document.getElementById('program-release').hidden"),false,'single-release examples keep an active version toolbar'); await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),['0.1.0','0.1.1']); @@ -363,11 +363,11 @@ try { await evaluate("document.getElementById('export').value='add';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value=JSON.stringify([{type:'number',value:'123456789'},{type:'number',value:'11'}]);document.getElementById('output-type').value='number'"); await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "123456800"'); - await evaluate("document.getElementById('program-picker').value='package:tricu-compiler';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); + await evaluate("document.getElementById('program-picker').value='package:tricu-compiler';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.1';document.getElementById('package-version').dispatchEvent(new Event('change'))"); await click('load-release');await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); assert.equal(await evaluate("document.getElementById('edit-source').hidden"),true); - assert.equal(await evaluate("document.getElementById('export').options.length"),1169); + assert.equal(await evaluate("document.getElementById('export').options.length"),1165); assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(compilerDescriptor.hash)); const indexedCompilerRequests=requests.filter(p=>p.endsWith('/dist/compiler-worker.js')).length; await click('tab-export-definition'); @@ -379,7 +379,7 @@ try { await click('tab-playground'); // Hash loading/sharing must recognize the indexed compiler too, not only its name. await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(compilerDescriptor.hash)};document.getElementById('program-hash').dispatchEvent(new Event('submit',{cancelable:true}))`);await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('export').options.length"),1169); + assert.equal(await evaluate("document.getElementById('export').options.length"),1165); await evaluate("document.getElementById('program-picker').value='package:tricu-source-builder';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); await click('load-release');await wait(status('Ready')); assert.deepEqual(await evaluate("[...document.getElementById('export').options].map(o=>o.value)"),['sourceBuildGraph']); @@ -649,6 +649,36 @@ try { const downloaded=Buffer.from(await evaluate('window.__bundleBytes')); assert.equal(pin(downloaded),catalogue.find(e=>e.id==='intro').hash); assert.equal(await evaluate('window.__bundleName'),pin(downloaded)+'.arboricx'); + for(const pane of ['tab-playground','tab-export-definition']){ + await click(pane); + await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); + await evaluate("document.getElementById('package-version').value='0.1.0';document.getElementById('package-version').dispatchEvent(new Event('change'))"); + assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false,'pending exact version downloads without Load'); + assert.equal(await evaluate("document.getElementById('download-bundle').closest('[role=tabpanel]').id"),pane==='tab-playground'?'playground':'export-definition'); + assert.equal(await evaluate("document.getElementById('download-bundle').closest('details')"),null); + const beforeCompile=requests.filter(p=>p.endsWith('/compiler.json')).length; + await click('download-bundle');await wait("document.getElementById('bundle-download-status').textContent.startsWith('Downloaded original verified bundle')"); + const expected=packageIndex.releases.find(r=>r.name==='rgb'&&r.version==='0.1.0').hash; + assert.equal(pin(Buffer.from(await evaluate('window.__bundleBytes'))),expected); + assert.equal(await evaluate("document.getElementById('run').disabled"),true,'download does not load an executable'); + assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); + assert.equal(requests.filter(p=>p.endsWith('/compiler.json')).length,beforeCompile); + } + await click('tab-playground'); + await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(published.id)};document.getElementById('bundle-hash').dispatchEvent(new Event('input'))`); + assert.equal(await evaluate("document.getElementById('program-release').hidden"),true); + assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false); + await click('download-bundle');await wait("document.getElementById('bundle-download-status').textContent.startsWith('Downloaded original verified bundle')"); + assert.equal(pin(Buffer.from(await evaluate('window.__bundleBytes'))),published.id); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + for(const id of ['parser','lexer']){ + await evaluate(`document.getElementById('program-picker').value='demo:${id}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); + assert.equal(await evaluate("document.getElementById('program-release').hidden"),false); + assert.equal(await evaluate("document.getElementById('package-version').disabled"),false); + } + // Return to the original editable selection for the draft-isolation controls. + await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); + console.log('PASS prominent shared download toolbar / all demo versions visible / selected version and direct hash download without Load / no implicit execution'); await evaluate("URL.createObjectURL=window.__createURL;document.removeEventListener('click',window.__bundleClick)"); await click('edit-source'); assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),false); @@ -985,6 +1015,11 @@ try { for(const id of ['tab-playground','tab-export-definition','tab-how']) { await click(id); assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,`${id} at ${width}px`); + if(id!=='tab-how'){ + const button=await evaluate("(()=>{const b=document.getElementById('download-bundle'),r=b.getBoundingClientRect();return {width:r.width,height:r.height,left:r.left,right:r.right,hidden:!!b.closest('[hidden]'),details:!!b.closest('details')};})()"); + assert.ok(button.width>0&&button.height>=44&&!button.hidden&&!button.details,'download is outside disclosures'); + assert.ok(button.left>=0&&button.right<=width,'download CTA fits the viewport'); + } } } for(const width of [320,390,1440]) { diff --git a/ext/demo-site/test/bundle.test.mjs b/ext/demo-site/test/bundle.test.mjs @@ -5,9 +5,9 @@ import {createHash} from 'node:crypto'; import {Graph} from '../dist/graph.js'; import {COMPILER_BUNDLE_LIMIT,decodeBundle,loadBundle,bundleHash,fetchBytes,fetchBundle,BUNDLE_LIMIT} from '../dist/bundle.js'; import {makeBundle,sizedBundle} from './bundle-fixture.mjs'; -// Audited publication: 1152 prior exports + 5 IO transport helpers, -// sourceObjectFlatTreeDomain, and 11 pattern-dispatch helpers; no removals. -const COMPILER_EXPORT_COUNT=1169; +// Current authenticated compiler closure excludes the four patterns exports; +// patterns now belongs to support. Older indexed compiler releases are retained. +const COMPILER_EXPORT_COUNT=1165; const data=JSON.parse(await readFile(new URL('../demos.json',import.meta.url))); test('stable packaged sources/pins and real compiler bundle decode',async()=>{ for(const [name,demo] of Object.entries(data.demos)){ diff --git a/ext/demo-site/test/packages.test.mjs b/ext/demo-site/test/packages.test.mjs @@ -104,9 +104,11 @@ test('both indexed compiler packages retain their exact independently authentica ['tricu-compiler','test/fixtures/compiler-library.arboricx','source_unit.compileStableModuleDefinitions'], ['tricu-source-builder','bootstrap/build-stable.arboricx','sourceBuildGraph'], ]){ - const release=releases.find(r=>r.name===name);assert.ok(release);assert.equal(release.demo,undefined); + const reference=await readFile(new URL('../../../'+path,import.meta.url)); + const referenceHash=createHash('sha256').update(reference).digest('hex'); + const release=releases.find(r=>r.name===name&&r.hash===referenceHash);assert.ok(release);assert.equal(release.demo,undefined); const bytes=await read('bundles/'+release.hash+'.arboricx'); - assert.deepEqual(bytes,await readFile(new URL('../../../'+path,import.meta.url))); + assert.ok(bytes.equals(reference),'indexed current artifact matches authenticated reference'); assert.equal(createHash('sha256').update(bytes).digest('hex'),release.hash); const {COMPILER_BUNDLE_LIMIT}=await import('../dist/bundle.js'); const bundle=decodeBundle(bytes,COMPILER_BUNDLE_LIMIT);assert.ok(bundle.nodes.length<=100000); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -152,3 +152,22 @@ test('source-first workspace has full-width source and explicit compilation',asy assert.match(css,/\.wrap\s*\{[^}]*max-width:1120px;[^}]*padding-left:28px;[^}]*padding-right:28px;/); assert.doesNotMatch(css,/\.workspace\s*\{[^}]*(?:max-width|width:|padding-inline|padding-left|padding-right)/); }); + +test('bundle download stays beside primary Load controls, never inside Advanced',async()=>{ + const html=await readFile(new URL('index.html',here),'utf8'); + const toolbar=html.slice(html.indexOf('id="package-toolbar"'),html.indexOf('<section class="bundle-details"')); + for(const id of ['program-release','package-version','load-release','program-hash','load','download-bundle'])assert.match(toolbar,new RegExp(`id="${id}"`)); + assert.equal((html.match(/id="download-bundle"/g)||[]).length,1); + assert.match(toolbar,/id="load-release" type="button" class="primary"/); + assert.match(toolbar,/id="load" type="submit" class="primary"/); + assert.doesNotMatch(toolbar,/id="download-bundle"[^>]*class="primary"|bundle-download-help/); + assert.match(toolbar,/Download \.arboricx/); + const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<details class="trace-panel"')); + assert.doesNotMatch(advanced,/download-bundle/); + const app=await readFile(new URL('src/app.ts',here),'utf8'); + assert.match(app,/el\('program-release'\)\.hidden = name === undefined;/); + assert.match(app,/r\.name === name && r\.version === versionPicker\.value/); + const css=await readFile(new URL('style.css',here),'utf8'); + assert.match(css,/\.release-choice select \{ width:9rem;/); + assert.match(css,/@media\(max-width:740px\)[\s\S]*#download-bundle \{ width:100%;/); +});