tricu

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

commit 54822e93e8ae3d7740ae0a2dbf0941ca678ee458
parent c1ac9fbc08f16f08bdf29434222bcdf5a35b4744
Author: James Eversole <james@eversole.co>
Date:   Mon, 19 Oct 2026 21:04:45 -0500

Add inline definition inspection and unified library exploration

Expose compiled definitions through source-gutter cards with selectable interpretations, revision invalidation, private draft scope, and cancellable observation on both browser runtimes. Keep expression evaluation and IO separate.

Promote library discovery into the toolbar, separate library and definition search, and provide draft-preserving import/try actions alongside source-free exploration. Add a short first-lesson introduction and update documentation and regression probes.

Validation: nix flake check; site build and generated JS parity; 131 serial Node tests; Chromium and Firefox inspection/import probes on Haskell and Zig, narrow layouts, beginner lessons, and editor/live-UX regressions.

Diffstat:
Mext/demo-site/README.md | 28++++++++++++++++++++++++++++
Mext/demo-site/dist/app.js | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Aext/demo-site/dist/definition-inspector.js | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/haskell-session.js | 8++++++++
Mext/demo-site/dist/import-picker.js | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mext/demo-site/dist/wasm-worker.js | 16++++++++++++++++
Mext/demo-site/index.html | 8+++++---
Mext/demo-site/reference-input/getting-started.md | 27+++++++++++++++++++++++++--
Mext/demo-site/src/app.ts | 53+++++++++++++++++++++++++++++++++++++++++++++--------
Mext/demo-site/src/compiler-protocol.ts | 2+-
Aext/demo-site/src/definition-inspector.ts | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/haskell-session.ts | 8++++++++
Mext/demo-site/src/import-picker.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mext/demo-site/src/protocol.ts | 6++++--
Mext/demo-site/src/runtime-session.ts | 1+
Mext/demo-site/src/wasm-worker.ts | 10++++++++++
Mext/demo-site/style.css | 15+++++++++++++++
Mext/demo-site/test/backend-browser.mjs | 1+
Mext/demo-site/test/basic-examples-probe.mjs | 11+++++------
Aext/demo-site/test/definition-inspector.test.mjs | 16++++++++++++++++
Mext/demo-site/test/import-picker-probe.mjs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mext/demo-site/test/live-ux-probe.mjs | 6+++++-
Mext/demo-site/test/site.test.mjs | 2+-
Mext/haskell-wasm/Reactor.hs | 8++++++++
Mext/haskell-wasm/browser-worker.mjs | 3+++
Mext/wasm/site-session.mjs | 5+++++
26 files changed, 797 insertions(+), 64 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -1424,3 +1424,31 @@ TEST_IDE=1 ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs n # TEST_BACKEND=zig-wasm selects the other production runtime. # Use backend-firefox.mjs and nixpkgs#firefox for Firefox. ``` + +### Inline definition inspection and library discovery + +The source gutter's **▶** opens an anchored compiled-definition card. Its +interpretation selector observes the existing binding directly; it never compiles +an expression, applies arguments, or runs the IO interpreter. Both runtime adapters +provide `inspectDefinition`, including private bindings in locally compiled module +scope. Published bundles contain only public roots. Observation can reduce or +diverge, and Stop terminates the owning Worker. Decoder failures preserve the +scope; fatal failures discard it. Source revisions invalidate inline output, and +successful compilation refreshes an open card. The separate evaluation panel and +its output/provenance are not changed by inline inspection. + +**Explore libraries…** is available in the toolbar with or without source. It +separates library and definition matches, offers clickable exports and existing +versioned references, and preserves the draft while browsing. **Try in expression** +checks/inserts the full module import and seeds an expression without running it. +Without editable source, **Try in Playground** loads the selected library. This +does not introduce selective imports or per-definition hash syntax. The Program +selector lists examples and, when applicable, the selected library rather than +the entire library catalogue. + +Validate both adapters and UI paths with `TEST_IMPORT_PICKER=1` in the bounded +Chromium harness above; add `TEST_BACKEND=zig-wasm` for Zig and +`TEST_VIEWPORT_WIDTH=390` for a narrow layout. The probe covers compiled/private +inspection, interpretation failure/recovery, revision invalidation, cancellable +observation, anchored cards, definition search, draft-preserving try, source-free +exploration, and the existing import safety/undo controls. diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -1,4 +1,5 @@ -import { exampleGroups, exampleGroup, selectorLibraries, libraryLabel } from './program-catalogue.js'; +import { definitionInspector } from './definition-inspector.js'; +import { exampleGroups, exampleGroup, libraryLabel } from './program-catalogue.js'; import { importPicker } from './import-picker.js'; import { localModulesUI } from './local-modules-ui.js'; import { outputReveal } from './output-reveal.js'; @@ -124,8 +125,52 @@ const localModules = localModulesUI(() => { queueCompile(); }); const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value, - available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, + available: () => !busy, + editable: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, + tryDefinition: name => { + if (storedOutput()) { + outputTarget.value = 'result'; + syncOutputTarget(); + } + guidedInputs.checked = false; + expressionEditor.value = name; + markPreviousResult(); + controls(busy); + expressionEditor.focus(); + expressionEditor.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, + tryLibrary: (library, definition) => { + loadBundle(library); + preferredExport = definition; + }, local: () => localModules.entries(), inputs: () => localModules.inputs() }); +const definitions = definitionInspector(sourceEditor, (id, name, output) => { + if (busy || !worker) + return; + operation = 'running'; + controls(true); + deadline.reset(Infinity); + clock(); + el('status').textContent = `Inspecting ${name}`; + worker.postMessage({ type: 'inspect', id, name, output }); +}); +function inspectionReply(data) { + if (data.type !== 'inspected' && data.type !== 'inspection-error') + return false; + if (!definitions.reply(data)) + return true; + if (data.type === 'inspection-error' && data.fatal) { + dispose(); + el('status').textContent = 'Inspection failed; runtime discarded'; + } + else { + pauseClock(); + operation = undefined; + controls(false); + el('status').textContent = draft ? 'Compiled' : 'Ready'; + } + return true; +} const exportModuleButton = el('export-compiled-module'); exportModuleButton.onclick = () => { if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) @@ -170,7 +215,8 @@ function controls(working) { sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); - el('add-import').hidden = !editableSource; + el('add-import').hidden = false; + el('playground-intro').hidden = selected?.id !== 'names-values' || !editableSource; el('add-import').disabled = busy; el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; el('source-availability').hidden = editableSource || !loaded; @@ -208,6 +254,7 @@ function controls(working) { outputUI.controls(busy); refreshSourceSyntax(); refreshExpressionSyntax(); + definitions.update(!sourceEditor.hidden && (draft ? compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision : loaded), busy, draft ? undefined : [...exportsInput.options].map(option => option.value)); } function dispose() { cancelIdleCompile(); @@ -304,6 +351,8 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { current.onmessage = ({ data }) => { if (worker !== current) return; + if (inspectionReply(data)) + return; switch (data.type) { case 'state': if (data.state === 'running') { @@ -442,6 +491,7 @@ function selectExample(entry) { loadBundle(latest); } function select(entry, release) { + definitions.reset(); sourceAvailable = false; draft = false; scratch = false; @@ -464,6 +514,7 @@ function select(entry, release) { newWorker(false); } function invalidateProgram() { + definitions.reset(); el('bundle-download-status').textContent = ''; sourceAvailable = false; draft = false; @@ -620,6 +671,15 @@ function releaseProgram(release) { } function chooseProgram(value, version) { el('bundle-download-status').textContent = ''; + if (value.startsWith('package:') && ![...programPicker.options].some(option => option.value === value)) { + for (const group of programPicker.querySelectorAll('optgroup')) + if (group.label === 'Exploring library') + group.remove(); + const group = document.createElement('optgroup'); + group.label = 'Exploring library'; + group.append(new Option(libraryLabel(value.slice(8)), value)); + programPicker.append(group); + } programPicker.value = value; const name = programPackage(value); el('program-release').hidden = name === undefined; @@ -659,18 +719,19 @@ function populatePrograms() { programPicker.append(group); } const packages = document.createElement('optgroup'); - packages.label = 'Libraries'; - for (const release of selectorLibraries(releases)) { - const option = document.createElement('option'); - option.value = `package:${release.name}`; - option.textContent = libraryLabel(release.name); - option.title = release.title; + packages.label = 'Exploring library'; + const currentName = selection && releases.find(r => r.hash === selection?.hash && !r.demo)?.name; + const currentRelease = releases.find(r => r.name === currentName); + if (currentRelease) { + const option = new Option(libraryLabel(currentRelease.name), `package:${currentRelease.name}`); packages.append(option); } const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…'; - programPicker.append(packages, hash); + if (packages.children.length) + programPicker.append(packages); + programPicker.append(hash); programPicker.disabled = false; } function showRelease(release) { @@ -877,6 +938,8 @@ function draftWorker(snapshot) { return; if ((data.type === 'compiled' || data.type === 'error') && discardStale()) return; + if ((data.type === 'inspected' || data.type === 'inspection-error') && inspectionReply(data)) + return; switch (data.type) { case 'module-exported': { idleDraft(); diff --git a/ext/demo-site/dist/definition-inspector.js b/ext/demo-site/dist/definition-inspector.js @@ -0,0 +1,200 @@ +import { tokenize } from './syntax.js'; +import { outputType } from './codecs.js'; +// Decoration only. The runtime resolves names; this never parses executable code. +export function definitionSites(source) { + const seen = new Set(); + return tokenize(source).filter(token => { + if (token.kind !== 'definition' || token.start > 0 && source[token.start - 1] !== '\n' || seen.has(token.text)) + return false; + seen.add(token.text); + return true; + }).map(token => ({ name: token.text, start: token.start })); +} +export function inspectionText(result) { + if ('file' in result) + return result.file.text; + if (result.type === 'tree' || result.type === 'list') + throw new Error('Choose a source interpretation for trees'); + return typeof result.value === 'string' ? result.value : JSON.stringify(result.value, null, 2); +} +export function definitionInspector(input, request) { + const wrapper = input.parentElement; + wrapper.classList.add('has-definition-inspector'); + const gutter = document.createElement('div'); + gutter.className = 'definition-gutter'; + wrapper.append(gutter); + const card = document.createElement('section'); + card.className = 'definition-card'; + card.hidden = true; + card.id = 'inline-definition'; + card.setAttribute('aria-label', 'Compiled definition'); + const title = document.createElement('strong'), format = document.createElement('select'); + format.setAttribute('aria-label', 'Definition interpretation'); + for (const [value, label] of [['tricu', 'tricu · t source'], ['triangle', '△ source'], ['dag', 'DAG · shared △'], ['ternary', 'Ternary'], ['string', 'Text'], ['number', 'Natural number'], ['bool', 'Boolean'], ['bytes', 'Bytes'], ['record', 'Scalar record']]) + format.add(new Option(label, value)); + const close = document.createElement('button'); + close.textContent = 'Close'; + close.type = 'button'; + const heading = document.createElement('div'); + heading.className = 'definition-card-heading'; + heading.append(title, format, close); + const status = document.createElement('p'); + status.className = 'hint'; + status.setAttribute('role', 'status'); + const result = document.createElement('pre'); + result.tabIndex = 0; + result.setAttribute('aria-label', 'Compiled definition result'); + card.append(heading, status, result); + wrapper.append(card); + let selected, source = '', ready = false, busy = false, serial = 0; + let pending, last = '', generation = 0, wasReady = false, scheduled = false; + const buttons = new Map(); + const declarationIndices = new Map(); + const forcedColors = matchMedia('(forced-colors: active)'); + forcedColors.addEventListener('change', position); + function position() { + const mirror = wrapper.querySelector('.syntax-viewport pre'); + if (!mirror) + return; + const declarations = [...mirror.querySelectorAll('.syntax-definition')]; + const rect = wrapper.getBoundingClientRect(); + for (const [name, button] of buttons) { + const span = declarations[declarationIndices.get(name) ?? -1]; + const top = span ? span.getBoundingClientRect().top - rect.top : -100; + button.textContent = forcedColors.matches ? `▶ ${name}` : '▶'; + button.style.top = `${top}px`; + button.hidden = !forcedColors.matches && (top < 0 || top + 20 > input.clientHeight); + } + if (selected && !card.hidden) { + const top = Number.parseFloat(buttons.get(selected)?.style.top ?? '0') + 26; + card.style.top = `${Math.max(8, Math.min(top, input.clientHeight - Math.min(card.offsetHeight, 260) - 8))}px`; + } + } + function inspect() { + if (!selected || !ready || busy || pending !== undefined) + return; + const key = `${generation}:${selected}:${format.value}`; + if (last === key) + return; + last = key; + pending = ++serial; + status.textContent = 'Reading compiled tree… Stop is available in the toolbar.'; + result.textContent = ''; + request(pending, selected, outputType(format.value)); + } + function schedule() { + if (scheduled) + return; + scheduled = true; + queueMicrotask(() => { scheduled = false; inspect(); }); + } + function choose(name) { + if (selected === name && !card.hidden) { + hide(); + return; + } + selected = name; + title.textContent = name; + card.hidden = false; + last = ''; + result.textContent = ''; + for (const [key, button] of buttons) + button.setAttribute('aria-expanded', String(key === name)); + position(); + schedule(); + } + function hide() { + const name = selected; + selected = undefined; + card.hidden = true; + for (const button of buttons.values()) + button.setAttribute('aria-expanded', 'false'); + buttons.get(name ?? '')?.focus(); + } + close.onclick = hide; + card.addEventListener('keydown', event => { if (event.key === 'Escape') { + event.preventDefault(); + hide(); + } }); + format.onchange = () => { last = ''; schedule(); }; + function update(current, working, publishedNames) { + ready = current; + busy = working; + if (ready && !wasReady) { + generation++; + last = ''; + } + wasReady = ready; + if (source !== input.value) { + source = input.value; + gutter.replaceChildren(); + buttons.clear(); + declarationIndices.clear(); + const declarations = tokenize(source).filter(token => token.kind === 'definition'); + for (const site of definitionSites(source)) { + declarationIndices.set(site.name, declarations.findIndex(token => token.start === site.start)); + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '▶'; + button.setAttribute('aria-label', `Inspect compiled definition ${site.name}`); + button.title = `Inspect ${site.name}`; + button.setAttribute('aria-controls', card.id); + button.setAttribute('aria-expanded', String(selected === site.name)); + button.onclick = () => choose(site.name); + gutter.append(button); + buttons.set(site.name, button); + } + if (selected && !buttons.has(selected)) { + selected = undefined; + card.hidden = true; + result.textContent = ''; + } + } + if (selected && publishedNames && !publishedNames.includes(selected)) { + selected = undefined; + pending = undefined; + last = ''; + card.hidden = true; + result.textContent = ''; + } + gutter.hidden = input.hidden; + for (const [name, button] of buttons) { + const available = !publishedNames || publishedNames.includes(name); + button.disabled = !ready || busy || !available; + button.title = available ? `Inspect ${name}` : `${name} is private in this published package. Edit source to compile locally.`; + } + format.disabled = !ready || busy; + if (!ready) { + pending = undefined; + last = ''; + if (selected) { + status.textContent = 'Source changed or runtime unavailable. Waiting for successful compilation.'; + result.textContent = ''; + } + } + position(); + schedule(); + } + input.addEventListener('scroll', position, { passive: true }); + new ResizeObserver(position).observe(input); + new MutationObserver(position).observe(wrapper.querySelector('.syntax-viewport'), { childList: true, subtree: true }); + return { + reset() { selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; for (const button of buttons.values()) + button.setAttribute('aria-expanded', 'false'); }, + update, + reply(message) { + if (message.id !== pending) + return false; + pending = undefined; + if (!selected || !ready) + return true; + status.textContent = message.type === 'inspection-error' ? `Cannot interpret this tree: ${message.message}` : `Compiled definition · ${format.selectedOptions[0].textContent} · No arguments applied; IO is not executed.`; + const value = message.type === 'inspected' ? inspectionText(message.result) : ''; + result.textContent = value.slice(0, 16_384); + if (value.length > 16_384) + status.textContent += ' · Preview: first 16,384 characters.'; + position(); + return true; + }, + }; +} diff --git a/ext/demo-site/dist/haskell-session.js b/ext/demo-site/dist/haskell-session.js @@ -55,6 +55,14 @@ export class HaskellSession { const value = await this.json({ type: 'module-snapshot' }); return moduleSnapshot(value.names, value.snapshot); } + async inspectDefinition(name, output) { + const message = { type: 'inspect-definition', name, output }; + if (isTreeResult(output)) { + const snapshot = await this.json(message); + return treeResult(snapshot.value, output); + } + return this.json(message); + } async run(name, args, output, mode, host) { return this.execute({ type: mode === 'pure' ? 'evaluate' : 'io-start', name, arguments: args, output }, output, mode, host); } diff --git a/ext/demo-site/dist/import-picker.js b/ext/demo-site/dist/import-picker.js @@ -6,6 +6,7 @@ export function importPicker(options) { const dialog = get('import-dialog'), open = get('add-import'); const search = get('import-search'), results = get('import-results'), detail = get('import-detail'); const alias = get('import-alias'), versions = get('import-version'); + const tryButton = get('try-library'); const status = get('import-status'), preview = get('import-preview'), insert = get('insert-import'); let libraries, selected, plan; let worker, timer, debounce; @@ -18,10 +19,15 @@ export function importPicker(options) { stop(); plan = undefined; insert.disabled = true; + tryButton.disabled = options.editable(); insert.textContent = 'Insert import'; preview.textContent = statement(); if (!selected) return; + if (!options.editable()) { + message('Browse references, copy an import, or try this library in the Playground.'); + return; + } if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); return; @@ -51,6 +57,7 @@ export function importPicker(options) { plan = data.plan; stop(); insert.disabled = false; + tryButton.disabled = false; insert.textContent = plan.existing ? 'Show existing import' : 'Insert import'; message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.'); }; @@ -63,7 +70,7 @@ export function importPicker(options) { results.hidden = true; get('import-browse').hidden = true; detail.hidden = false; - get('import-title').textContent = choice.title; + get('import-title').textContent = choice.definition ? `${choice.definition} · ${choice.title}` : choice.title; get('import-description').textContent = choice.description; get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => { if (i % 2 === 0) @@ -77,6 +84,10 @@ export function importPicker(options) { get('import-example-details').hidden = !choice.library?.example; get('import-usage-details').open = false; get('import-example-details').open = false; + get('import-definition').textContent = choice.definition ? `Definition: ${choice.definition} · from ${choice.title}. Adding an import makes the whole library available.` : 'Explore exports below. Imports make the whole library available.'; + tryButton.hidden = !choice.library && !options.editable(); + tryButton.textContent = options.editable() ? 'Try in expression' : 'Try in Playground'; + get('copy-qualified').hidden = !choice.definition; alias.value = choice.alias; versions.replaceChildren(); versions.hidden = !choice.library; @@ -101,7 +112,18 @@ export function importPicker(options) { } function renderExports() { const query = get('import-export-search').value.toLowerCase(); - get('import-exports').textContent = selected?.names.filter(n => n.toLowerCase().includes(query)).join('\n') || 'No matching exports.'; + const list = get('import-exports'); + list.replaceChildren(); + for (const name of selected?.names.filter(n => n.toLowerCase().includes(query)) ?? []) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = name; + button.onclick = () => { if (selected) + show({ ...selected, alias: alias.value, definition: name }, false); }; + list.append(button); + } + if (!list.children.length) + list.textContent = 'No matching exports.'; } function browse() { stop(); @@ -118,12 +140,13 @@ export function importPicker(options) { ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })), ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })), ].filter(row => matchesLibrary(row, search.value)); - for (const choice of choices) { + const heading = (text) => { const h = document.createElement('h3'); h.textContent = text; results.append(h); }; + const addChoice = (choice) => { const button = document.createElement('button'); button.type = 'button'; button.dataset['manifest'] = choice.manifest; const title = document.createElement('strong'); - title.textContent = choice.title + (choice.library ? ' · ' + choice.library.version : ''); + title.textContent = (choice.definition ? choice.definition + ' · ' : '') + choice.title + (choice.library ? ' · ' + choice.library.version : ''); const description = document.createElement('span'); description.textContent = choice.description; const label = document.createElement('small'); @@ -132,6 +155,27 @@ export function importPicker(options) { button.append(title, description, label); button.onclick = () => show(choice); results.append(button); + }; + heading('Libraries'); + for (const choice of choices) + addChoice(choice); + if (search.value.trim()) { + heading('Definitions'); + const query = search.value.toLowerCase().trim(); + const matches = choices.flatMap(choice => choice.names.filter(name => name.toLowerCase().includes(query)).map(definition => ({ ...choice, definition }))); + matches.sort((a, b) => Number(b.definition.toLowerCase() === query) - Number(a.definition.toLowerCase() === query) || a.definition.localeCompare(b.definition)); + for (const match of matches.slice(0, 80)) + addChoice(match); + if (matches.length > 80) { + const note = document.createElement('p'); + note.textContent = 'Showing 80 definitions. Refine your search for more specific results.'; + results.append(note); + } + if (!matches.length) { + const note = document.createElement('p'); + note.textContent = 'No matching definitions.'; + results.append(note); + } } if (!choices.length) results.textContent = 'No matching libraries. Try an exported name or a broader search.'; @@ -159,25 +203,48 @@ export function importPicker(options) { alias.oninput = check; get('import-export-search').oninput = renderExports; versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) - show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row }, false); }; + show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row, ...(selected?.definition && row.names.includes(selected.definition) ? { definition: selected.definition } : {}) }, false); }; get('import-back').onclick = () => { browse(); search.focus(); }; get('import-cancel').onclick = finish; dialog.addEventListener('cancel', () => stop()); dialog.addEventListener('close', () => { if (!dialog.open) stop(); }); + tryButton.onclick = () => { + if (options.editable()) { + applyPlan(true); + return; + } + if (selected?.library) { + const choice = selected; + finish(); + options.tryLibrary(choice.library, choice.definition); + } + }; + const qualifiedName = (name) => { + const namespace = plan?.existing?.alias ?? alias.value; + return namespace === '*' ? name : `${namespace}.${name}`; + }; + get('copy-qualified').onclick = () => { + if (!selected?.definition) + return; + void navigator.clipboard.writeText(qualifiedName(selected.definition)).then(() => message('Qualified name copied.'), () => message('Clipboard unavailable. Use the namespace and definition shown above.')); + }; get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); }; - insert.onclick = () => { + function applyPlan(trying = false) { if (!plan || !options.available() || options.source.value !== snapshot || options.backend() !== backend) { stop(); insert.disabled = true; message('The editor changed. Close and reopen the picker to use the current source.'); return; } - const checked = plan; + const checked = plan, name = selected?.definition ?? selected?.names[0]; + const qualified = name ? qualifiedName(name) : undefined; finish(); options.source.focus(); if (checked.existing) { options.source.setSelectionRange(checked.existing.start, checked.existing.end); + if (trying && qualified) + options.tryDefinition(qualified); return; } // Native textarea editing supplies one undo transaction on supported browsers. @@ -188,7 +255,10 @@ export function importPicker(options) { return; } options.source.setSelectionRange(0, checked.statement.length); - }; + if (trying && qualified) + options.tryDefinition(qualified); + } + insert.onclick = () => applyPlan(); return { cancel: () => { if (dialog.open) finish(); } }; } diff --git a/ext/demo-site/dist/wasm-worker.js b/ext/demo-site/dist/wasm-worker.js @@ -90,6 +90,22 @@ scope.onmessage = ({ data }) => { send({ type: 'module-export-error', message: String(e) }); } } + else if (m['type'] === 'inspect') { + if (!session) + throw new Error('Load or compile first'); + if (!Number.isSafeInteger(m['id']) || typeof m['name'] !== 'string' || m['name'].length > 128) + throw new Error('Invalid inspection'); + const id = m['id']; + try { + // Observe the existing binding with no arguments, expression compilation, + // or IO interpreter. Observation/decoding can still require reduction. + const result = await session.inspectDefinition(m['name'], outputType(m['output'])); + send({ type: 'inspected', id, result }); + } + catch (e) { + send({ type: 'inspection-error', id, message: e instanceof Error ? e.message : String(e), fatal: session.disposed }); + } + } else if (m['type'] === 'run') { if (!session) throw new Error('Load or compile first'); diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -43,19 +43,20 @@ <div class="editor-toolbar" data-inspector-background> <div class="toolbar-selection"><label for="program-picker">Program</label><select id="program-picker" disabled><option value="">Loading Names and values…</option></select></div> <div class="toolbar-runtime"><label for="runtime-backend">Runtime</label><select id="runtime-backend"><option value="haskell-wasm" selected>Haskell</option><option value="zig-wasm">Zig</option></select></div> - <div class="toolbar-actions"><button id="stop" disabled hidden>Stop</button><button id="settings-toggle" aria-controls="settings-panel" aria-expanded="false">Settings</button></div> + <div class="toolbar-actions"><button id="add-import" disabled>Explore libraries…</button><button id="stop" disabled hidden>Stop</button><button id="settings-toggle" aria-controls="settings-panel" aria-expanded="false">Settings</button></div> <span id="status" role="status">Loading Names and values</span> </div> <div id="editor-body" class="editor-body"> <div class="editor-main" data-inspector-background> <section class="source-panel" aria-labelledby="source-title"> <div class="source-heading"><h2 id="source-title">Source</h2><span id="source-label">tricu / stable</span></div> + <p id="playground-intro" class="hint" hidden><strong>Try the Playground:</strong> click ▶ beside a definition to inspect its compiled tree and choose an interpretation. To try a call or run IO, scroll to <a href="#expression-inputs">Expression</a> below and click <strong>Evaluate</strong>.</p> <p id="authorship">Published package</p><p id="source-availability" class="hint" hidden></p> <label class="sr-only" for="source-editor">tricu source (up to 16,384 characters)</label> <textarea id="source-editor" rows="18" maxlength="32768" spellcheck="false" autocapitalize="off" autocorrect="off" hidden></textarea> <pre id="source-notes"><code id="source">Loading Names and values…</code></pre> <p id="compile-diagnostic" class="error" role="status" hidden></p><p id="compile-activity" class="hint" role="status" hidden></p> - <div class="source-actions"><button id="add-import" disabled hidden>Add import…</button><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. Evaluation is always explicit.</span></div> + <div class="source-actions"><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. Evaluation is always explicit.</span></div> </section> <section class="expression-dock" aria-label="Evaluation"> <div class="output-toolbar" role="group" aria-label="Evaluation settings"> @@ -119,13 +120,14 @@ </div></section> </main> <dialog id="import-dialog" aria-labelledby="import-dialog-title"> - <div class="import-heading"><h2 id="import-dialog-title">Add a library</h2><button id="import-cancel">Cancel</button></div> + <div class="import-heading"><h2 id="import-dialog-title">Explore libraries</h2><button id="import-cancel">Close</button></div> <div id="import-content" class="import-content"> <div id="import-browse"><label for="import-search">Search libraries or exported names</label><input id="import-search" type="search" autocomplete="off"></div> <div id="import-results" aria-label="Libraries"></div> <section id="import-detail" hidden> <button id="import-back">← Back to libraries</button><h3 id="import-title" tabindex="-1"></h3><p id="import-description"></p> <label id="import-version-label" for="import-version">Version</label><select id="import-version"></select> + <p id="import-definition" class="hint"></p><button id="try-library">Try in Playground</button><button id="copy-qualified" hidden>Copy qualified name</button> <label for="import-alias">Import as</label><input id="import-alias" maxlength="128" spellcheck="false" autocomplete="off"> <pre id="import-preview" tabindex="0"></pre> <p><a id="import-reference" target="_blank" rel="noopener">Calling conventions and source reference ↗</a></p> diff --git a/ext/demo-site/reference-input/getting-started.md b/ext/demo-site/reference-input/getting-started.md @@ -32,10 +32,33 @@ imports. `!export answer` selects the public bundle interface; private definitio are also usable in the editor's expression scope. Every native module needs at least one export. `!export *` exposes its local definitions. +## Inspect a compiled definition + +Click **▶** beside a top-level definition to open its tree inside the source +editor. Choose an interpretation to read it as source, a natural number, text, +or another supported encoding. Interpretations assign meaning to tree shapes; +an incompatible decoder reports an error without changing the definition. +No arguments are applied and IO is never executed. The separate Expression and +Evaluate controls remain available below Source for calls and IO. + +Open inspections refresh after successful compilation. Editing, failed +compilation, and Stop clear the old tree. Published packages expose only their +public definitions; private bindings become inspectable after editing compiles +the source locally. Reading/decoding trees can require reduction and can diverge; +**Stop** terminates the owning runtime. Inline text previews show at most 16,384 +characters; use the evaluation panel for complete copy/download output. + ## Find and import libraries -Use **Add import…** below Source. Search by library purpose or exported name, -select a library, inspect its exports/reference, choose a namespace, then insert. +Use **Explore libraries…** in the toolbar. Search by library purpose or exported +name; Libraries and Definitions have separate result groups. Select a definition +to see its library, namespace and reference, or browse a library's clickable exports. +Choose a namespace, then **Insert import** or **Try in expression**. Trying inserts +(or reuses) the module import and seeds Expression without executing it or replacing +your authored source. Imports still acquire the whole module, not one definition. +Without editable source, **Try in Playground** loads the published library instead. +Browsing and closing the explorer never change your draft. The Program selector +focuses on examples, with a compact entry for the currently explored library. The picker checks the candidate with the native compiler and inserts one undoable edit. Existing imports have a jump action; ambiguous or unfinished source can use **Copy import**. Cancel closes the check. Installed local files appear alongside diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,4 +1,5 @@ -import { exampleGroups, exampleGroup, selectorLibraries, libraryLabel } from './program-catalogue.js'; +import { definitionInspector } from './definition-inspector.js'; +import { exampleGroups, exampleGroup, libraryLabel } from './program-catalogue.js'; import { importPicker } from './import-picker.js'; import { localModulesUI } from './local-modules-ui.js'; import { outputReveal } from './output-reveal.js'; @@ -108,8 +109,31 @@ const localModules = localModulesUI(() => { queueCompile(); }); const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value, - available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, + available: () => !busy, + editable: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, + tryDefinition: name => { + if (storedOutput()) { outputTarget.value = 'result'; syncOutputTarget(); } + guidedInputs.checked = false; expressionEditor.value = name; markPreviousResult(); controls(busy); + expressionEditor.focus(); expressionEditor.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, + tryLibrary: (library, definition) => { + loadBundle(library); + preferredExport = definition; + }, local: () => localModules.entries(), inputs: () => localModules.inputs() }); +const definitions = definitionInspector(sourceEditor, (id, name, output) => { + if (busy || !worker) return; + operation = 'running'; controls(true); deadline.reset(Infinity); clock(); + el('status').textContent = `Inspecting ${name}`; + worker.postMessage({ type: 'inspect', id, name, output }); +}); +function inspectionReply(data: WorkerMessage): boolean { + if (data.type !== 'inspected' && data.type !== 'inspection-error') return false; + if (!definitions.reply(data)) return true; + if (data.type === 'inspection-error' && data.fatal) { dispose(); el('status').textContent = 'Inspection failed; runtime discarded'; } + else { pauseClock(); operation = undefined; controls(false); el('status').textContent = draft ? 'Compiled' : 'Ready'; } + return true; +} const exportModuleButton = el<HTMLButtonElement>('export-compiled-module'); exportModuleButton.onclick = () => { if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) return; @@ -145,7 +169,8 @@ function controls(working: boolean): void { compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); compileButton.hidden = !draft; restoreButton.hidden = !draft; sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); - el<HTMLButtonElement>('add-import').hidden = !editableSource; + el<HTMLButtonElement>('add-import').hidden = false; + el('playground-intro').hidden = selected?.id !== 'names-values' || !editableSource; el<HTMLButtonElement>('add-import').disabled = busy; el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; el('source-availability').hidden = editableSource || !loaded; @@ -169,6 +194,7 @@ function controls(working: boolean): void { shareButton.disabled = busy || draft || !loaded; el<HTMLButtonElement>('reset').disabled = busy; outputUI.controls(busy); refreshSourceSyntax(); refreshExpressionSyntax(); + definitions.update(!sourceEditor.hidden && (draft ? compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision : loaded), busy, draft ? undefined : [...exportsInput.options].map(option => option.value)); } function dispose(): void { cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; @@ -216,6 +242,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa const send = (message: ClientMessage): void => current.postMessage(message); current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => { if (worker !== current) return; + if (inspectionReply(data)) return; switch (data.type) { case 'state': if (data.state === 'running') { pending = undefined; clock(); el('status').textContent = operation === 'loading' ? 'Loading package' : 'Running'; } @@ -296,6 +323,7 @@ function selectExample(entry: CatalogueEntry): void { else loadBundle(latest); } function select(entry: CatalogueEntry, release?: Release): void { + definitions.reset(); sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; @@ -308,6 +336,7 @@ function select(entry: CatalogueEntry, release?: Release): void { newWorker(false); } function invalidateProgram(): void { + definitions.reset(); el('bundle-download-status').textContent = ''; sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; @@ -391,6 +420,11 @@ function releaseProgram(release: Release): string { } function chooseProgram(value: string, version?: string): void { el('bundle-download-status').textContent = ''; + if (value.startsWith('package:') && ![...programPicker.options].some(option => option.value === value)) { + for (const group of programPicker.querySelectorAll('optgroup')) if (group.label === 'Exploring library') group.remove(); + const group = document.createElement('optgroup'); group.label = 'Exploring library'; + group.append(new Option(libraryLabel(value.slice(8)), value)); programPicker.append(group); + } programPicker.value = value; const name = programPackage(value); el('program-release').hidden = name === undefined; @@ -416,13 +450,15 @@ function populatePrograms(): void { } if (group.children.length) programPicker.append(group); } - const packages = document.createElement('optgroup'); packages.label = 'Libraries'; - for (const release of selectorLibraries(releases)) { - const option = document.createElement('option'); option.value = `package:${release.name}`; - option.textContent = libraryLabel(release.name); option.title = release.title; packages.append(option); + const packages = document.createElement('optgroup'); packages.label = 'Exploring library'; + const currentName = selection && releases.find(r => r.hash === selection?.hash && !r.demo)?.name; + const currentRelease = releases.find(r => r.name === currentName); + if (currentRelease) { + const option = new Option(libraryLabel(currentRelease.name), `package:${currentRelease.name}`); packages.append(option); } const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…'; - programPicker.append(packages, hash); programPicker.disabled = false; + if (packages.children.length) programPicker.append(packages); + programPicker.append(hash); programPicker.disabled = false; } function showRelease(release?: Release): void { el('module-import-help').hidden = release === undefined; @@ -540,6 +576,7 @@ function draftWorker(snapshot: { revision: number; source: string }): Worker { current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => { if (worker !== current) return; if ((data.type === 'compiled' || data.type === 'error') && discardStale()) return; + if ((data.type === 'inspected' || data.type === 'inspection-error') && inspectionReply(data)) return; switch (data.type) { case 'module-exported': { idleDraft(); diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts @@ -3,7 +3,7 @@ import type { RuntimeStats } from './runtime-backends.js'; import type { OutputType } from './codecs.js'; import type { RequestId, WorkerMessage } from './protocol.js'; import type { LocalModuleInput } from './local-modules.js'; -export type CompilerClient = { type: 'compile'; source: unknown; environment?: string; localModules?: LocalModuleInput[] } +export type CompilerClient = Extract<import('./protocol.js').ClientMessage, { type: 'inspect' }> | { type: 'compile'; source: unknown; environment?: string; localModules?: LocalModuleInput[] } | { type: 'export-module' } | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'resume'; id: RequestId; value?: string }; diff --git a/ext/demo-site/src/definition-inspector.ts b/ext/demo-site/src/definition-inspector.ts @@ -0,0 +1,126 @@ +import { tokenize } from './syntax.js'; +import { outputType, type OutputType, type ValueView } from './codecs.js'; +import type { InspectionReply } from './protocol.js'; + +// Decoration only. The runtime resolves names; this never parses executable code. +export function definitionSites(source: string): { name: string; start: number }[] { + const seen = new Set<string>(); + return tokenize(source).filter(token => { + if (token.kind !== 'definition' || token.start > 0 && source[token.start - 1] !== '\n' || seen.has(token.text)) return false; + seen.add(token.text); return true; + }).map(token => ({ name: token.text, start: token.start })); +} +export function inspectionText(result: ValueView): string { + if ('file' in result) return result.file.text; + if (result.type === 'tree' || result.type === 'list') throw new Error('Choose a source interpretation for trees'); + return typeof result.value === 'string' ? result.value : JSON.stringify(result.value, null, 2); +} +export function definitionInspector(input: HTMLTextAreaElement, request: (id: number, name: string, output: OutputType) => void) { + const wrapper = input.parentElement!; + wrapper.classList.add('has-definition-inspector'); + const gutter = document.createElement('div'); gutter.className = 'definition-gutter'; wrapper.append(gutter); + const card = document.createElement('section'); card.className = 'definition-card'; card.hidden = true; + card.id = 'inline-definition'; card.setAttribute('aria-label', 'Compiled definition'); + const title = document.createElement('strong'), format = document.createElement('select'); + format.setAttribute('aria-label', 'Definition interpretation'); + for (const [value, label] of [['tricu', 'tricu · t source'], ['triangle', '△ source'], ['dag', 'DAG · shared △'], ['ternary', 'Ternary'], ['string', 'Text'], ['number', 'Natural number'], ['bool', 'Boolean'], ['bytes', 'Bytes'], ['record', 'Scalar record']]) format.add(new Option(label, value)); + const close = document.createElement('button'); close.textContent = 'Close'; close.type = 'button'; + const heading = document.createElement('div'); heading.className = 'definition-card-heading'; heading.append(title, format, close); + const status = document.createElement('p'); status.className = 'hint'; status.setAttribute('role', 'status'); + const result = document.createElement('pre'); result.tabIndex = 0; result.setAttribute('aria-label', 'Compiled definition result'); + card.append(heading, status, result); wrapper.append(card); + let selected: string | undefined, source = '', ready = false, busy = false, serial = 0; + let pending: number | undefined, last = '', generation = 0, wasReady = false, scheduled = false; + const buttons = new Map<string, HTMLButtonElement>(); + const declarationIndices = new Map<string, number>(); + const forcedColors = matchMedia('(forced-colors: active)'); + forcedColors.addEventListener('change', position); + function position(): void { + const mirror = wrapper.querySelector('.syntax-viewport pre'); if (!mirror) return; + const declarations = [...mirror.querySelectorAll<HTMLElement>('.syntax-definition')]; + const rect = wrapper.getBoundingClientRect(); + for (const [name, button] of buttons) { + const span = declarations[declarationIndices.get(name) ?? -1]; + const top = span ? span.getBoundingClientRect().top - rect.top : -100; + button.textContent = forcedColors.matches ? `▶ ${name}` : '▶'; + button.style.top = `${top}px`; button.hidden = !forcedColors.matches && (top < 0 || top + 20 > input.clientHeight); + } + if (selected && !card.hidden) { + const top = Number.parseFloat(buttons.get(selected)?.style.top ?? '0') + 26; + card.style.top = `${Math.max(8, Math.min(top, input.clientHeight - Math.min(card.offsetHeight, 260) - 8))}px`; + } + } + function inspect(): void { + if (!selected || !ready || busy || pending !== undefined) return; + const key = `${generation}:${selected}:${format.value}`; if (last === key) return; + last = key; pending = ++serial; status.textContent = 'Reading compiled tree… Stop is available in the toolbar.'; result.textContent = ''; + request(pending, selected, outputType(format.value)); + } + function schedule(): void { + if (scheduled) return; scheduled = true; + queueMicrotask(() => { scheduled = false; inspect(); }); + } + function choose(name: string): void { + if (selected === name && !card.hidden) { hide(); return; } + selected = name; title.textContent = name; card.hidden = false; last = ''; result.textContent = ''; + for (const [key, button] of buttons) button.setAttribute('aria-expanded', String(key === name)); + position(); schedule(); + } + function hide(): void { + const name = selected; selected = undefined; card.hidden = true; + for (const button of buttons.values()) button.setAttribute('aria-expanded', 'false'); + buttons.get(name ?? '')?.focus(); + } + close.onclick = hide; + card.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); hide(); } }); + format.onchange = () => { last = ''; schedule(); }; + function update(current: boolean, working: boolean, publishedNames?: string[]): void { + ready = current; busy = working; + if (ready && !wasReady) { generation++; last = ''; } + wasReady = ready; + if (source !== input.value) { + source = input.value; gutter.replaceChildren(); buttons.clear(); declarationIndices.clear(); + const declarations = tokenize(source).filter(token => token.kind === 'definition'); + for (const site of definitionSites(source)) { + declarationIndices.set(site.name, declarations.findIndex(token => token.start === site.start)); + const button = document.createElement('button'); button.type = 'button'; button.textContent = '▶'; + button.setAttribute('aria-label', `Inspect compiled definition ${site.name}`); button.title = `Inspect ${site.name}`; + button.setAttribute('aria-controls', card.id); button.setAttribute('aria-expanded', String(selected === site.name)); button.onclick = () => choose(site.name); + gutter.append(button); buttons.set(site.name, button); + } + if (selected && !buttons.has(selected)) { selected = undefined; card.hidden = true; result.textContent = ''; } + } + if (selected && publishedNames && !publishedNames.includes(selected)) { + selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; + } + gutter.hidden = input.hidden; + for (const [name, button] of buttons) { + const available = !publishedNames || publishedNames.includes(name); + button.disabled = !ready || busy || !available; + button.title = available ? `Inspect ${name}` : `${name} is private in this published package. Edit source to compile locally.`; + } + format.disabled = !ready || busy; + if (!ready) { + pending = undefined; last = ''; + if (selected) { status.textContent = 'Source changed or runtime unavailable. Waiting for successful compilation.'; result.textContent = ''; } + } + position(); schedule(); + } + input.addEventListener('scroll', position, { passive: true }); + new ResizeObserver(position).observe(input); + new MutationObserver(position).observe(wrapper.querySelector('.syntax-viewport')!, { childList: true, subtree: true }); + return { + reset(): void { selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; for (const button of buttons.values()) button.setAttribute('aria-expanded', 'false'); }, + update, + reply(message: InspectionReply): boolean { + if (message.id !== pending) return false; + pending = undefined; + if (!selected || !ready) return true; + status.textContent = message.type === 'inspection-error' ? `Cannot interpret this tree: ${message.message}` : `Compiled definition · ${format.selectedOptions[0]!.textContent} · No arguments applied; IO is not executed.`; + const value = message.type === 'inspected' ? inspectionText(message.result) : ''; + result.textContent = value.slice(0, 16_384); + if (value.length > 16_384) status.textContent += ' · Preview: first 16,384 characters.'; + position(); return true; + }, + }; +} diff --git a/ext/demo-site/src/haskell-session.ts b/ext/demo-site/src/haskell-session.ts @@ -60,6 +60,14 @@ export class HaskellSession { const value = await this.json<{ names: string[]; snapshot: GraphPreview }>({ type: 'module-snapshot' }); return moduleSnapshot(value.names, value.snapshot); } + async inspectDefinition(name: string, output: OutputType): Promise<ValueView> { + const message = { type: 'inspect-definition', name, output }; + if (isTreeResult(output)) { + const snapshot = await this.json<{ value: GraphPreview }>(message); + return treeResult(snapshot.value, output); + } + return this.json<ValueView>(message); + } async run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView> { return this.execute({ type: mode === 'pure' ? 'evaluate' : 'io-start', name, arguments: args, output }, output, mode, host); } diff --git a/ext/demo-site/src/import-picker.ts b/ext/demo-site/src/import-picker.ts @@ -3,11 +3,14 @@ import { libraryIndex, latestLibraries, matchesLibrary, type Library } from './l import { packageReleases } from './packages.js'; import type { ImportPlan } from './import-plan.js'; import type { LocalModuleInput } from './local-modules.js'; -interface Choice { title: string; description: string; manifest: string; names: string[]; alias: string; library?: Library; } +interface Choice { title: string; description: string; manifest: string; names: string[]; alias: string; library?: Library; definition?: string; } interface Options { source: HTMLTextAreaElement; backend(): string; available(): boolean; + editable(): boolean; + tryDefinition(name: string): void; + tryLibrary(library: Library, definition?: string): void; local(): { label: string; manifest: string; names: string[] }[]; inputs(): LocalModuleInput[]; } @@ -16,6 +19,7 @@ export function importPicker(options: Options): { cancel(): void } { const dialog = get<HTMLDialogElement>('import-dialog'), open = get<HTMLButtonElement>('add-import'); const search = get<HTMLInputElement>('import-search'), results = get('import-results'), detail = get('import-detail'); const alias = get<HTMLInputElement>('import-alias'), versions = get<HTMLSelectElement>('import-version'); + const tryButton = get<HTMLButtonElement>('try-library'); const status = get('import-status'), preview = get('import-preview'), insert = get<HTMLButtonElement>('insert-import'); let libraries: Library[] | undefined, selected: Choice | undefined, plan: ImportPlan | undefined; let worker: Worker | undefined, timer: ReturnType<typeof setTimeout> | undefined, debounce: ReturnType<typeof setTimeout> | undefined; @@ -25,8 +29,9 @@ export function importPicker(options: Options): { cancel(): void } { const message = (text: string): void => { status.textContent = text; }; const statement = (): string => selected ? `!import ${selected.manifest} as ${alias.value}` : ''; function check(): void { - stop(); plan = undefined; insert.disabled = true; insert.textContent = 'Insert import'; preview.textContent = statement(); + stop(); plan = undefined; insert.disabled = true; tryButton.disabled = options.editable(); insert.textContent = 'Insert import'; preview.textContent = statement(); if (!selected) return; + if (!options.editable()) { message('Browse references, copy an import, or try this library in the Playground.'); return; } if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); return; } message('Checking with the native compiler…'); const current = revision; @@ -41,7 +46,7 @@ export function importPicker(options: Options): { cancel(): void } { if (current !== revision || !dialog.open) return; if (data.type === 'error') { fail(data.message ?? 'Import check failed.'); return; } if (data.type !== 'import-checked' || !data.plan) return; - plan = data.plan; stop(); insert.disabled = false; + plan = data.plan; stop(); insert.disabled = false; tryButton.disabled = false; insert.textContent = plan.existing ? 'Show existing import' : 'Insert import'; message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.'); }; @@ -50,13 +55,17 @@ export function importPicker(options: Options): { cancel(): void } { } function show(choice: Choice, focus = true): void { selected = choice; get('import-actions').hidden = false; results.hidden = true; get('import-browse').hidden = true; detail.hidden = false; - get('import-title').textContent = choice.title; get('import-description').textContent = choice.description; + get('import-title').textContent = choice.definition ? `${choice.definition} · ${choice.title}` : choice.title; get('import-description').textContent = choice.description; get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => { if (i % 2 === 0) return document.createTextNode(text); const code = document.createElement('code'); code.textContent = text; return code; })); get('import-usage-details').hidden = !choice.library?.usage; get('import-example').textContent = choice.library?.example ?? ''; get('import-example-details').hidden = !choice.library?.example; (get('import-usage-details') as HTMLDetailsElement).open = false; (get('import-example-details') as HTMLDetailsElement).open = false; + get('import-definition').textContent = choice.definition ? `Definition: ${choice.definition} · from ${choice.title}. Adding an import makes the whole library available.` : 'Explore exports below. Imports make the whole library available.'; + tryButton.hidden = !choice.library && !options.editable(); + tryButton.textContent = options.editable() ? 'Try in expression' : 'Try in Playground'; + get<HTMLButtonElement>('copy-qualified').hidden = !choice.definition; alias.value = choice.alias; versions.replaceChildren(); versions.hidden = !choice.library; get('import-version-label').hidden = !choice.library; if (choice.library) for (const release of packageReleases(libraries!, choice.library.name)) { @@ -69,7 +78,13 @@ export function importPicker(options: Options): { cancel(): void } { } function renderExports(): void { const query = get<HTMLInputElement>('import-export-search').value.toLowerCase(); - get('import-exports').textContent = selected?.names.filter(n => n.toLowerCase().includes(query)).join('\n') || 'No matching exports.'; + const list = get('import-exports'); list.replaceChildren(); + for (const name of selected?.names.filter(n => n.toLowerCase().includes(query)) ?? []) { + const button = document.createElement('button'); button.type = 'button'; button.textContent = name; + button.onclick = () => { if (selected) show({ ...selected, alias: alias.value, definition: name }, false); }; + list.append(button); + } + if (!list.children.length) list.textContent = 'No matching exports.'; } function browse(): void { stop(); selected = undefined; plan = undefined; detail.hidden = true; get('import-actions').hidden = true; get('import-content').scrollTop = 0; get('import-browse').hidden = false; results.hidden = false; message(''); results.replaceChildren(); @@ -77,14 +92,26 @@ export function importPicker(options: Options): { cancel(): void } { ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })), ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })), ].filter(row => matchesLibrary(row, search.value)); - for (const choice of choices) { + const heading = (text: string): void => { const h = document.createElement('h3'); h.textContent = text; results.append(h); }; + const addChoice = (choice: Choice): void => { const button = document.createElement('button'); button.type = 'button'; button.dataset['manifest'] = choice.manifest; - const title = document.createElement('strong'); title.textContent = choice.title + (choice.library ? ' · ' + choice.library.version : ''); + const title = document.createElement('strong'); title.textContent = (choice.definition ? choice.definition + ' · ' : '') + choice.title + (choice.library ? ' · ' + choice.library.version : ''); const description = document.createElement('span'); description.textContent = choice.description; const label = document.createElement('small'); const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5); label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`; button.append(title, description, label); button.onclick = () => show(choice); results.append(button); + }; + heading('Libraries'); + for (const choice of choices) addChoice(choice); + if (search.value.trim()) { + heading('Definitions'); + const query = search.value.toLowerCase().trim(); + const matches = choices.flatMap(choice => choice.names.filter(name => name.toLowerCase().includes(query)).map(definition => ({ ...choice, definition }))); + matches.sort((a, b) => Number(b.definition.toLowerCase() === query) - Number(a.definition.toLowerCase() === query) || a.definition.localeCompare(b.definition)); + for (const match of matches.slice(0, 80)) addChoice(match); + if (matches.length > 80) { const note = document.createElement('p'); note.textContent = 'Showing 80 definitions. Refine your search for more specific results.'; results.append(note); } + if (!matches.length) { const note = document.createElement('p'); note.textContent = 'No matching definitions.'; results.append(note); } } if (!choices.length) results.textContent = 'No matching libraries. Try an exported name or a broader search.'; } @@ -100,15 +127,29 @@ export function importPicker(options: Options): { cancel(): void } { } }; search.oninput = browse; alias.oninput = check; get<HTMLInputElement>('import-export-search').oninput = renderExports; - versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row }, false); }; + versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row, ...(selected?.definition && row.names.includes(selected.definition) ? { definition: selected.definition } : {}) }, false); }; get('import-back').onclick = () => { browse(); search.focus(); }; get('import-cancel').onclick = finish; dialog.addEventListener('cancel', () => stop()); dialog.addEventListener('close', () => { if (!dialog.open) stop(); }); + tryButton.onclick = () => { + if (options.editable()) { applyPlan(true); return; } + if (selected?.library) { const choice = selected; finish(); options.tryLibrary(choice.library!, choice.definition); } + }; + const qualifiedName = (name: string): string => { + const namespace = plan?.existing?.alias ?? alias.value; + return namespace === '*' ? name : `${namespace}.${name}`; + }; + get('copy-qualified').onclick = () => { + if (!selected?.definition) return; + void navigator.clipboard.writeText(qualifiedName(selected.definition)).then(() => message('Qualified name copied.'), () => message('Clipboard unavailable. Use the namespace and definition shown above.')); + }; get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); }; - insert.onclick = () => { + function applyPlan(trying = false): void { if (!plan || !options.available() || options.source.value !== snapshot || options.backend() !== backend) { stop(); insert.disabled = true; message('The editor changed. Close and reopen the picker to use the current source.'); return; } - const checked = plan; finish(); options.source.focus(); - if (checked.existing) { options.source.setSelectionRange(checked.existing.start, checked.existing.end); return; } + const checked = plan, name = selected?.definition ?? selected?.names[0]; + const qualified = name ? qualifiedName(name) : undefined; + finish(); options.source.focus(); + if (checked.existing) { options.source.setSelectionRange(checked.existing.start, checked.existing.end); if (trying && qualified) options.tryDefinition(qualified); return; } // Native textarea editing supplies one undo transaction on supported browsers. options.source.setSelectionRange(0, 0); if (!document.execCommand('insertText', false, checked.statement)) { @@ -116,6 +157,8 @@ export function importPicker(options: Options): { cancel(): void } { dialog.showModal(); return; } options.source.setSelectionRange(0, checked.statement.length); - }; + if (trying && qualified) options.tryDefinition(qualified); + } + insert.onclick = () => applyPlan(); return { cancel: () => { if (dialog.open) finish(); } }; } diff --git a/ext/demo-site/src/protocol.ts b/ext/demo-site/src/protocol.ts @@ -11,12 +11,14 @@ export interface CatalogueEntry { environment?: string; presets?: Record<string, { arguments: unknown[]; output: OutputType; mode: 'pure' | 'io' }>; } -export type ClientMessage = { type: 'load'; hash: BundleHash; localId?: string } +export type InspectionReply = { type: 'inspected'; id: number; result: ValueView } | { type: 'inspection-error'; id: number; message: string; fatal: boolean }; +export type ClientMessage = { type: 'inspect'; id: number; name: string; output: OutputType } + | { type: 'load'; hash: BundleHash; localId?: string } | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'download' } | { type: 'export'; export: string; format: TreeFormat } | { type: 'resume'; id: RequestId; value?: string }; -export type WorkerMessage = { type: 'module-exported'; bytes: Uint8Array; hash: BundleHash; manifest: string } +export type WorkerMessage = InspectionReply | { type: 'module-exported'; bytes: Uint8Array; hash: BundleHash; manifest: string } | { type: 'module-export-error'; message: string } | { type: 'state'; state: 'running' } | { type: 'state'; state: 'input' | 'step'; id: RequestId } diff --git a/ext/demo-site/src/runtime-session.ts b/ext/demo-site/src/runtime-session.ts @@ -15,6 +15,7 @@ export interface RuntimeSession { // Read-only native header inspection in an initialized compiler session. inspectModule(source: string): Promise<ModuleStage>; exportModule(): Promise<Uint8Array>; + inspectDefinition(name: string, output: OutputType): Promise<ValueView>; run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; runExpression(source: string, output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; stats(): RuntimeStats; diff --git a/ext/demo-site/src/wasm-worker.ts b/ext/demo-site/src/wasm-worker.ts @@ -64,6 +64,16 @@ scope.onmessage = ({ data }) => { running(); try { const module = await validateLocalModule(await session.exportModule()); send({ type: 'module-exported', bytes: module.bytes, hash: module.hash, manifest: module.manifest }); } catch (e) { if (session.disposed) throw e; send({ type: 'module-export-error', message: String(e) }); } + } else if (m['type'] === 'inspect') { + if (!session) throw new Error('Load or compile first'); + if (!Number.isSafeInteger(m['id']) || typeof m['name'] !== 'string' || m['name'].length > 128) throw new Error('Invalid inspection'); + const id = m['id'] as number; + try { + // Observe the existing binding with no arguments, expression compilation, + // or IO interpreter. Observation/decoding can still require reduction. + const result = await session.inspectDefinition(m['name'], outputType(m['output'])); + send({ type: 'inspected', id, result }); + } catch (e) { send({ type: 'inspection-error', id, message: e instanceof Error ? e.message : String(e), fatal: session.disposed }); } } else if (m['type'] === 'run') { if (!session) throw new Error('Load or compile first'); if (typeof m['export'] !== 'string' || m['export'].length > 512) throw new Error('Invalid export'); diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -59,3 +59,18 @@ header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)} #theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:0;background:transparent;color:var(--green);font-size:24px;line-height:1} #theme-toggle:hover{background:var(--panel)} + +/* Definition controls live beside the source, not over its editable text. */ +#source-editor.syntax-input{padding-left:48px} +.definition-gutter{position:absolute;inset:0 auto 0 0;width:38px;overflow:hidden;pointer-events:none;z-index:2} +.definition-gutter button{position:absolute;left:5px;width:28px;height:24px;padding:0;border:0;background:transparent;color:#bfe2ba;font-size:13px;pointer-events:auto} +.definition-gutter button:hover,.definition-gutter button[aria-expanded="true"]{background:#385045;color:#fff} +.definition-card{position:absolute;left:40px;right:12px;z-index:3;max-height:260px;overflow:auto;margin:0;padding:12px;border:1px solid #587362;border-radius:8px;background:#21352b;color:#e3edde;min-width:0} +.definition-card-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.definition-card-heading strong{flex:1;overflow-wrap:anywhere} +.definition-card select,.definition-card button{background:#21352b;color:#e3edde;border-color:#587362;max-width:100%;font-size:13px} +.definition-card .hint{color:#b1c1b7}.definition-card pre{max-height:220px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0 0} +#playground-intro{margin:0;padding:8px 20px 14px;color:#d2e0ce;font-size:13px}#playground-intro a{color:inherit;text-decoration:underline} +#import-exports{display:flex;gap:6px;flex-wrap:wrap}#import-exports button{font:inherit;font-size:13px;padding:5px 8px} +#import-results h3{margin-bottom:0}#try-library,#copy-qualified{margin:8px 8px 8px 0} + +@media(forced-colors:active){.definition-gutter{position:static;width:auto;display:flex;flex-wrap:wrap;gap:8px;padding:8px;pointer-events:auto}.definition-gutter button{position:static;width:auto;height:auto;border:1px solid ButtonText;color:ButtonText}.definition-card{background:Canvas;color:CanvasText;border-color:CanvasText}.definition-card .hint,.definition-card button,.definition-card select{color:CanvasText;background:Canvas}} diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -47,6 +47,7 @@ try { socket.send(JSON.stringify({ id, method, params })); }); await send('Runtime.enable'); await send('Page.enable'); + if(process.env.TEST_VIEWPORT_WIDTH){const width=Number(process.env.TEST_VIEWPORT_WIDTH);assert(Number.isInteger(width)&&width>=320);await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<741});} const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); diff --git a/ext/demo-site/test/basic-examples-probe.mjs b/ext/demo-site/test/basic-examples-probe.mjs @@ -6,7 +6,7 @@ async function status(want){for(let i=0;i<4500;i++){if(el('status').textContent= export async function run(control,backend='haskell-wasm'){ await status('Ready'); const groups=[...el('program-picker').querySelectorAll('optgroup')]; - assert(groups.map(g=>g.label).join('|')==='Basic Examples|Practical Examples|Language & Runtime Explorations|Libraries','Purposeful selector groups'); + assert(groups.map(g=>g.label).join('|')==='Basic Examples|Practical Examples|Language & Runtime Explorations','Purposeful selector groups'); const basics=['names-values','arithmetic','functions','lists','transform-list','choices']; assert([...groups[0].children].map(o=>o.value).join('|')===basics.map(n=>'demo:'+n).join('|'),'Basic progression'); assert(groups[2].firstElementChild.value==='demo:language-reference','Reference leads explorations'); @@ -16,10 +16,9 @@ export async function run(control,backend='haskell-wasm'){ change('runtime-backend',backend);await status('Ready'); const base=new URL('../',document.querySelector('script[type=module][src]').src); const releases=(await(await fetch(new URL('packages.json',base))).json()).releases; - const visible=[...groups[3].children],manifests=visible.map(o=>releases.find(r=>r.name===o.value.slice(8))?.manifest); - assert(new Set(manifests).size===manifests.length,'One selector row per module identity'); - assert(visible.every(o=>!o.textContent.startsWith('tricu-')),'Friendly library labels'); - assert(visible.some(o=>o.value==='package:tricu-lib-native-math')&&!visible.some(o=>o.value==='package:tricu-math'),'Complete library publication preferred'); + assert(![...el('program-picker').options].some(o=>o.value.startsWith('package:')),'Libraries are discovered in the explorer, not a giant selector list'); + assert(!el('playground-intro').hidden&&el('playground-intro').textContent.includes('▶'),'First lesson explains inspection and evaluation'); + assert(el('add-import').textContent==='Explore libraries…','Prominent library explorer'); const select=async name=>{change('program-picker','demo:'+name);await status('Ready');}; const evaluate=async(expression,output,expected)=>{input('expression-editor',expression);change('output-type',output);el('run').click();await status('Finished');assert(el('result').textContent===expected,expression+' expected '+expected+' got '+el('result').textContent);}; const defaults=[['greeting','string','Hello, world!'],['total','number','12'],['double 6','number','12'],['count','number','3'],['total','number','24'],['message 0','string','Your basket is empty.']]; @@ -50,5 +49,5 @@ export async function run(control,backend='haskell-wasm'){ location.hash=new URLSearchParams({bundle:old.hash,demo:'intro',export:'answer'}).toString();await delay(50);await status('Ready'); assert(el('program-picker').value==='hash','Retired link remains a direct bundle selection');await evaluate('answer','number','42'); await select('names-values'); - return {userAgent:navigator.userAgent,checks:['beginner default; grouped selector; identity-based library deduplication; all six lesson defaults and exercises; restoration; all reference presets before editing; editable reference; retained historical link'],backend}; + return {userAgent:navigator.userAgent,checks:['beginner default; grouped selector; library exploration separated from example selection; all six lesson defaults and exercises; restoration; all reference presets before editing; editable reference; retained historical link'],backend}; } diff --git a/ext/demo-site/test/definition-inspector.test.mjs b/ext/demo-site/test/definition-inspector.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {definitionSites,inspectionText} from '../dist/definition-inspector.js'; +test('inspection sites are unique top-level heads, never comments, strings or local binders',()=>{ + const source='!export answer\n-- fake = 1\nanswer = 42\nf [] = 0\nf xs = 1\ntext = "not = a definition"\nouter x =\n let local = x\n in local\n|- hidden = 2 -|'; + assert.deepEqual(definitionSites('outer = let\n answer = 1\n in answer\nanswer = 42').map(s=>s.name),['outer','answer']); + assert.deepEqual(definitionSites(source).map(s=>s.name),['answer','f','text','outer']); + for(const site of definitionSites(source))assert.equal(source.slice(site.start,site.start+site.name.length),site.name); +}); +test('inspection text respects decoder output and preserves zero/false/empty text',()=>{ + assert.equal(inspectionText({type:'number',value:'0'}),'0'); + assert.equal(inspectionText({type:'bool',value:false}),'false'); + assert.equal(inspectionText({type:'string',value:''}),''); + assert.equal(inspectionText({type:'tricu',value:'ignored',file:{text:'t',extension:'tri',bytes:1}}),'t'); + assert.throws(()=>inspectionText({type:'tree',value:{}}),/source interpretation/); +}); diff --git a/ext/demo-site/test/import-picker-probe.mjs b/ext/demo-site/test/import-picker-probe.mjs @@ -8,7 +8,7 @@ export async function run(control,backend='haskell-wasm'){ const base=new URL('../',document.querySelector('script[type=module][src]').src); const {createRuntimeSession}=(await import(new URL('dist/probe-api.js',base))).runtimeSession; const catalogue=await(await fetch(new URL('reference/libraries.json',base))).json(); - const lib=name=>catalogue.releases.find(r=>r.name==='tricu-lib-'+name&&r.version==='0.1.0'); + const lib=name=>catalogue.releases.find(r=>r.name==='tricu-lib-'+name&&r.version==='0.3.0'); const math=lib('native-math'); // Static references and independently specified examples can be consumed without // a page session or a JavaScript module catalogue. @@ -25,7 +25,12 @@ export async function run(control,backend='haskell-wasm'){ assert(result.value===example.expected,'Documented result '+example.id+': '+JSON.stringify(result)); assert(terminal===example.terminal,'Documented terminal '+example.id); } - await session.compile('!export answer\nanswer = 42','empty'); + await session.compile('!export answer\nanswer = private\nprivate = 42','empty'); + assert((await session.inspectDefinition('private','number')).value==='42','Compiled private binding can be inspected without an expression'); + const tree=await session.inspectDefinition('answer','tricu');assert(tree.file.text.length>0,'Compiled tree source'); + let invalid=false;try{await session.inspectDefinition('private','string');}catch{invalid=true;} + assert(invalid,'Invalid interpretation rejected'); + assert((await session.inspectDefinition('answer','number')).value==='42','Decoder failure preserves definitions'); const stage=await session.inspectModule(`-- 🐕\n!import ${math.manifest} as N\n!export answer\nanswer = 1`); assert(stage.imports[0][0]===math.manifest&&stage.imports[0][1]==='N','Native header response'); assert((await session.runExpression('answer','number','pure',host)).value==='42','Inspection preserves compiled scope'); @@ -34,20 +39,44 @@ export async function run(control,backend='haskell-wasm'){ assert((await session.runExpression('answer','number','pure',host)).value==='42','Failed inspection preserves compiled scope'); }finally{session.dispose();} await status('Ready');change('runtime-backend',backend);await status('Ready'); + assert(!el('playground-intro').hidden,'Brief Playground tutorial on arrival'); + const inspectButton=name=>document.querySelector(`[aria-label="Inspect compiled definition ${name}"]`); + inspectButton('greeting').click();await wait(()=>!document.querySelector('.definition-card select').disabled,'published inspection'); + const interpretation=value=>{const select=document.querySelector('.definition-card select');select.value=value;select.dispatchEvent(new Event('change'));}; + interpretation('string');await wait(()=>document.querySelector('.definition-card pre').textContent==='Hello, world!','published text interpretation'); + assert(el('result').textContent===''&&el('terminal').textContent==='','Inspection does not alter evaluator output or IO'); + interpretation('number');await wait(()=>document.querySelector('.definition-card [role=status]').textContent.includes('Cannot interpret'),'invalid view'); + interpretation('string');await wait(()=>document.querySelector('.definition-card pre').textContent==='Hello, world!','view recovery'); + document.querySelector('.definition-card button').click(); document.querySelector('[data-compiler-example=module]').click();await status('Compiled'); const original='-- preserve this comment 🐕\n!export answer\nanswer = 42'; + input('source-editor','!export answer\nidentity x = x\nanswer = identity private\nprivate = 42');await status('Compiled'); + inspectButton('private').click();interpretation('number');await wait(()=>document.querySelector('.definition-card pre').textContent==='42','private inline value'); + const cardBounds=document.querySelector('.definition-card').getBoundingClientRect(), editorBounds=el('source-editor').getBoundingClientRect(); + assert(cardBounds.top>=editorBounds.top&&cardBounds.bottom<=editorBounds.bottom,'Inline result is anchored inside source viewport'); + input('source-editor','!export answer\nidentity x = x\nanswer = identity private\nprivate = 7'); + assert(document.querySelector('.definition-card pre').textContent==='','Edit invalidates old inline value immediately'); + await wait(()=>document.querySelector('.definition-card pre').textContent==='7','open inspection refreshes after compilation'); + assert(el('expression-editor').value==='answer','Inline inspection does not change expression'); + input('source-editor','!export answer\nanswer = (');await status('Compilation failed'); + assert(document.querySelector('.definition-card pre').textContent==='','Failed compilation cannot display previous tree'); + input('source-editor','!export answer\nanswer = 42\nloop = (x : x x) (x : x x)');await status('Compiled'); + inspectButton('loop').click();await wait(()=>el('status').textContent==='Inspecting loop','divergent observation started'); + el('stop').click();await wait(()=>!el('add-import').disabled,'inspection Stop'); + assert(document.querySelector('.definition-card pre').textContent==='','Stop discards unfinished inspection'); input('source-editor',original);await status('Compiled'); const open=async()=>{el('add-import').click();await wait(()=>el('import-results').querySelector('button'),'library list');assert(el('import-dialog').open,'Modal open');}; - const select=async()=>{input('import-search','remainder');await wait(()=>el('import-results').querySelector(`[data-manifest="${math.manifest}"]`),'search export');el('import-results').querySelector(`[data-manifest="${math.manifest}"]`).click();}; + const select=async()=>{input('import-search','remainder');await wait(()=>el('import-results').querySelector(`[data-manifest="${math.manifest}"]`),'search export');el('import-results').querySelector(`[data-manifest="${math.manifest}"]`).click();input('import-alias','N');}; await open();await select(); assert(el('import-title').textContent==='native-math','Search reaches native math'); - assert(el('import-preview').textContent===`!import ${math.manifest} as N`,'Exact full manifest preview'); - assert(el('import-version').value==='0.1.0','Newest version initially selected'); - assert(el('import-version').options.length===2,'History available in details'); + assert(el('source-editor').value===original,'Browsing preserves draft'); + assert(el('import-preview').textContent===`!import ${math.manifest} as N`,'Exact full manifest preview: '+el('import-preview').textContent); + assert(el('import-version').value==='0.3.0','Newest version initially selected'); + assert(el('import-version').options.length>=2,'History available in details: '+[...el('import-version').options].map(o=>o.value)); + input('import-alias','Numbers');change('import-version','0.1.0');await wait(()=>!el('insert-import').disabled,'historical release check'); + assert(el('import-version').value==='0.1.0'&&el('import-preview').textContent.endsWith('as Numbers'),'History preserves chosen namespace'); assert(el('import-usage').textContent.includes('multiply')&&el('import-example').textContent.includes('Expected result: 42'),'Calling conventions and tested example available inline'); - input('import-alias','Numbers');change('import-version','0.0.9');await wait(()=>!el('insert-import').disabled,'historical release check'); - assert(el('import-version').value==='0.0.9'&&el('import-preview').textContent.endsWith('as Numbers'),'History preserves chosen namespace'); - change('import-version','0.1.0');input('import-alias','N'); + change('import-version','0.3.0');input('import-alias','N'); await wait(()=>!el('insert-import').disabled,'candidate compiled'); el('insert-import').click(); assert(!el('import-dialog').open,'Insertion closes dialog'); @@ -78,5 +107,21 @@ export async function run(control,backend='haskell-wasm'){ await open();input('import-search','<svg');assert(!el('import-results').querySelector('svg'),'Filename is text');el('import-results').querySelector('button').click(); assert(el('import-version').hidden,'Local file has no invented version');await wait(()=>!el('insert-import').disabled,'local import check');el('insert-import').click();await status('Compiled'); input('expression-editor','Local.add 5 6');el('run').click();await status('Finished');assert(el('result').textContent==='11','Local library from unified picker'); - return {userAgent:navigator.userAgent,checks:['static agent references; complete published interfaces; general documented examples; native read-only header inspection; export search; exact imports; undo; existing import jump; namespace conflicts; malformed source copy fallback; cancellation; local-file discovery'],backend}; + await open();input('import-search','remainder'); + const definition=[...el('import-results').querySelectorAll('button')].find(button=>button.querySelector('strong').textContent.startsWith('remainder ·')); + assert(definition,'Definition matches have their own results');definition.click(); + assert(el('import-definition').textContent.includes('Definition: remainder'),'Direct definition detail'); + await wait(()=>!el('try-library').disabled,'definition try checked'); + const beforeTry=el('source-editor').value;el('try-library').click(); + assert(el('source-editor').value.endsWith(beforeTry),'Trying a definition preserves authored source'); + assert(el('expression-editor').value.endsWith('.remainder'),'Try seeds qualified definition without executing'); + await wait(()=>!el('run').disabled,'expression ready'); + // Source-free library exploration is available and never inserts into a hidden editor. + change('program-picker','hash');input('bundle-hash',math.hash);el('program-hash').dispatchEvent(new Event('submit',{cancelable:true}));await status('Ready'); + assert(el('source-editor').hidden&&!el('add-import').hidden,'Explorer available without source'); + await open();input('import-search','remainder');[...el('import-results').querySelectorAll('button')].find(button=>button.querySelector('strong').textContent.startsWith('native-math ·')).click(); + assert(el('insert-import').disabled&&!el('try-library').hidden,'Source-free exploration offers try rather than insertion'); + el('try-library').click();await status('Ready'); + assert(el('program-picker').value==='package:tricu-lib-native-math','Explorer loads selected library'); + return {userAgent:navigator.userAgent,checks:['inline compiled/public/private definitions; interpretation recovery; edit refresh and failed-compile invalidation; cancellable observation; anchored result card; separate definition search; draft-preserving try; source-free explorer; static agent references; complete published interfaces; general documented examples; native read-only header inspection; export search; exact imports; undo; existing import jump; namespace conflicts; malformed source copy fallback; cancellation; local-file discovery'],backend}; } diff --git a/ext/demo-site/test/live-ux-probe.mjs b/ext/demo-site/test/live-ux-probe.mjs @@ -53,7 +53,11 @@ export async function run(control,backend='haskell-wasm',alreadyUsed=false){ assert([...el('export').options].map(x=>x.value).join(',')===exports,'Expressions never publish temporary names'); // No editable source required: bind exact published library exports to trees. assert(el('settings-panel').hidden,'Package loading must not require opening Settings'); - change('program-picker','package:tricu-lib-packages-base');await status('Ready'); + change('program-picker','hash');el('add-import').click(); + await wait(()=>el('import-results').querySelector('button')); + input('import-search','packages-base'); + [...el('import-results').querySelectorAll('button')].find(button=>button.querySelector('strong').textContent.startsWith('packages-base ·')).click(); + el('try-library').click();await status('Ready'); assert(!el('run').disabled&&el('settings-panel').hidden,'Selecting a package makes it usable without a hidden Load action'); assert(el('result').textContent===''&&el('terminal').textContent==='','Package selection only loads; it never evaluates'); assert(el('source-editor').hidden,'Library has no editable source'); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -76,7 +76,7 @@ test('hero invitation, direct footer feedback and self-hosted preview metadata', test('import picker and static agent entrypoint are discoverable and accessible',async()=>{ const html=await read('index.html'); - assert.match(html,/<button id="add-import"[^>]*>Add import…<\/button>/); + assert.match(html,/<button id="add-import"[^>]*>Explore libraries…<\/button>/); assert.match(html,/<dialog id="import-dialog" aria-labelledby="import-dialog-title">/); for(const id of ['import-search','import-alias','import-version','import-export-search'])assert.ok(html.includes(`for="${id}"`)); assert.match(html,/<link rel="help" type="text\/plain" href=".\/llms.txt"/); diff --git a/ext/haskell-wasm/Reactor.hs b/ext/haskell-wasm/Reactor.hs @@ -299,6 +299,14 @@ request ptr len = guardRequest $ do writeIORef expressionBindings defs writeIORef pendingModule Nothing writeIORef ioResult Nothing + ("inspect-definition",[nameBytes,codec])->do + requireIdle + name<-R.utf8 nameBytes + kind<-R.utf8 codec + defs<-readIORef expressionBindings + term<-maybe (fail "missing definition") pure (lookup name defs) + value<-R.view kind term + reply("ok\n"++R.json value) ("evaluate",name:codec:dag:roots)->do kind<-R.utf8 codec term<-select name dag roots diff --git a/ext/haskell-wasm/browser-worker.mjs b/ext/haskell-wasm/browser-worker.mjs @@ -133,6 +133,9 @@ onmessage=async({data:m})=>{ const bundle=await safe(()=>authenticatedBundle(m.bytes,m.hash)); const fields=await safe(()=>loadFields(bundle)); bytes=operation(fields);storedBundle=bundle; + }else if(m.type==='inspect-definition'){ + const fields=await safe(()=>{const f=evaluation(m.name,[],m.output);return ['inspect-definition',f[1],f[2]];}); + bytes=operation(fields); }else if(m.type==='evaluate'){ const fields=await safe(()=>evaluation(m.name,m.arguments,m.output)); bytes=operation(fields); diff --git a/ext/wasm/site-session.mjs b/ext/wasm/site-session.mjs @@ -154,6 +154,11 @@ export async function create({base,compiler}) { return [...definitions.keys()]; }finally{if(!g.poisoned&&!closed)g.collect();} });}, + inspectDefinition(name,output){return exclusive(async()=>{ + const root=(expressionScope??definitions).get(name);if(root===undefined)throw Error('Missing definition'); + output=codec.outputType(output); + try{return g.scope(()=>view(root,output));}finally{if(!g.poisoned)g.collect();} + });}, run(name,args,output,mode,host){ output=codec.outputType(output); return execute(name,args,mode,host,h=>view(h,output));