Post Step 4 Completion

This commit is contained in:
2026-09-14 07:57:18 -07:00
parent db973a86f3
commit 446bf3533e
52 changed files with 32034 additions and 3 deletions
+127
View File
@@ -0,0 +1,127 @@
import { ExhibitHost } from './host.js';
import { argumentSchema } from './validation.js';
import { postMessageTransport } from './transport/post-message.js';
const host = new ExhibitHost();
const byId = id => document.getElementById(id);
const json = value => JSON.stringify(value, null, 2);
const rows = new Map();
let frame = null, renderedCatalog = null;
function element(tag, text, parent) {
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
parent?.append(node); return node;
}
function editor(spec, label, parent) {
const wrapper = element('label', label, parent);
const options = spec.enum;
const input = element(options ? 'select' : 'input', undefined, wrapper);
input.setAttribute('aria-label', label);
if (options) {
for (const [index, value] of options.entries()) {
const option = element('option', spec.optionLabels?.[index] ?? String(value), input); option.value = String(index);
}
} else {
input.type = spec.type === 'boolean' ? 'checkbox' : ['number', 'integer'].includes(spec.type) ? 'number' : 'text';
for (const key of ['min', 'max', 'step', 'minLength', 'maxLength']) if (spec[key] !== undefined) input[key] = spec[key];
if (input.type === 'number' && spec.step === undefined) input.step = spec.type === 'integer' ? '1' : 'any';
}
return {
input,
read() { return options ? options[Number(input.value)] : spec.type === 'boolean' ? input.checked
: ['number', 'integer'].includes(spec.type) ? (input.value === '' ? NaN : Number(input.value)) : input.value; },
write(value) {
if (options) input.value = String(options.findIndex(v => Object.is(v, value)));
else if (spec.type === 'boolean') input.checked = value === true;
else input.value = value ?? '';
}
};
}
function buildCatalog() {
rows.clear(); byId('catalog').replaceChildren();
for (const target of host.catalog) {
const section = element('article', undefined, byId('catalog')); section.className = 'target';
element('h3', target.label ?? target.id, section); element('code', target.id, section);
element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section);
const current = element('output', '', section);
const detail = element('details', undefined, section); element('summary', 'Target descriptor', detail); element('pre', json(target), detail);
const notice = element('p', '', section); notice.className = 'notice';
const form = element('form', undefined, section); form.className = 'controls'; form.noValidate = true;
let valueEditor, schemaError = '', argumentEditors = [];
if (target.kind === 'impulse') {
try {
for (const arg of argumentSchema(target)) {
const group = element('div', undefined, form);
let include = null;
if (!arg.required) { const l = element('label', `Include ${arg.label ?? arg.name}`, group); include = element('input', undefined, l); include.type = 'checkbox'; }
const field = editor(arg, arg.label ?? arg.name, group);
if (arg.description) element('p', arg.description, group);
argumentEditors.push({ arg, include, field });
}
} catch (error) { schemaError = error.message; }
} else if (target.writable) {
if (target.kind === 'selection') valueEditor = editor({ enum: target.options.map(o => o.value), optionLabels: target.options.map(o => o.label) }, 'New value', form);
else if (target.kind === 'range' || ['boolean', 'number', 'integer', 'string'].includes(target.valueType)) {
valueEditor = editor({ ...target, type: target.kind === 'range' ? 'number' : target.valueType }, 'New value', form);
} else schemaError = 'Unsupported state value type.';
}
let submit = null;
let row;
if (target.kind === 'impulse' || target.writable) {
submit = element('button', target.kind === 'impulse' ? 'Invoke' : 'Set', form);
form.addEventListener('submit', async event => {
event.preventDefault(); if (row.busy) return; row.busy = true; submit.disabled = true;
try {
if (target.kind === 'impulse') {
const args = Object.create(null);
for (const { arg, include, field } of argumentEditors) if (!include || include.checked) args[arg.name] = field.read();
await host.invoke(target.id, args);
} else await host.set(target.id, valueEditor.read());
} catch { /* Host reports every control failure in the visible console. */ }
row.busy = false; render();
});
}
row = { target, current, notice, submit, schemaError, valueEditor, initialized: false, busy: false };
rows.set(target.id, row);
}
}
function render() {
byId('status').textContent = `${host.status} · ${host.sync}`;
byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit,
registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence });
byId('capabilities').textContent = json(host.capabilities);
byId('state').textContent = json(Object.fromEntries(host.values));
byId('events').textContent = json(host.eventLog);
byId('errors').textContent = json(host.logs);
byId('target-count').textContent = `(${host.catalog.length})`;
byId('refresh').disabled = host.status !== 'connected';
byId('reconnect').disabled = !frame;
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
for (const row of rows.values()) {
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
row.valueEditor.write(host.values.get(row.target.id)); row.initialized = true;
}
const unavailable = host.unavailable(row.target);
row.notice.textContent = row.schemaError || (unavailable.length ? `Unavailable: ${unavailable.join(', ')}` : '');
if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0;
}
}
async function attach() {
if (!frame) return;
try { await host.connect(postMessageTransport(frame)); } catch (error) { if (!error.code) host.report(error); }
}
byId('connection').addEventListener('submit', event => {
event.preventDefault();
try {
const url = new URL(byId('exhibit-url').value, location.href);
if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) throw new Error('Enter a same-origin HTTP exhibit URL.');
host.disconnect(); frame?.remove();
frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
frame.addEventListener('load', attach); frame.src = url.href;
byId('frame-container').append(frame); render();
} catch (error) { host.report(error); }
});
byId('reconnect').addEventListener('click', attach);
byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); });
byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
host.subscribe(render); render();