generated from Labyricorn/labyricorn-project-template
One commit for the work accumulated in the working tree since Step 6.3, which had never been split into per-step commits: - src/local-surfaces.js + src/surface-url.js (new); src/ui.js, src/validation.js, src/connection.js and public/index.html updated for local-surface hosting and generic surface rendering - tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7) new; connection/museum-gallery/surface-validation suites updated - reference exhibits: shared/contract-core.js defaults to Contract 5.3 (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface catalog; aquarium/haunted-house/planetarium adapters updated - SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js, Observation-surface boot branch, local-change hooks, view.pillars / view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT - SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on its presence/type, matching the host's own envelope validation; the adapter now advertises contract minor 3 / version 5.3.0, which it already implemented via the 5.3 surfaces field. Root cause of the five failing postmessage-interop tests (host hello was silently dropped). - docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs; test-fixtures/PROVENANCE.md resync record Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7); SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check clean for changed files; two pre-existing trailing-whitespace lines remain in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from the authoritative SciFi source. Step 6.7 live verification (browser Observation, packaged standalone) is still pending and is not claimed here.
217 lines
13 KiB
JavaScript
217 lines
13 KiB
JavaScript
import { ExhibitHost } from './host.js';
|
|
import { argumentSchema } from './validation.js';
|
|
import { postMessageTransport } from './transport/post-message.js';
|
|
import { ExhibitConnection } from './connection.js';
|
|
import { LocalSurfaces } from './local-surfaces.js';
|
|
|
|
const host = new ExhibitHost();
|
|
const byId = id => document.getElementById(id);
|
|
const json = value => JSON.stringify(value, null, 2);
|
|
const rows = new Map();
|
|
let renderedCatalog = null;
|
|
let renderedSurfaces = null;
|
|
const surfaceRows = new Map();
|
|
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
|
|
createFrame() {
|
|
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
|
|
// Append only after src is assigned to avoid negotiating with the blank document.
|
|
queueMicrotask(() => { if (connection.frame === frame) byId('frame-container').append(frame); });
|
|
return frame;
|
|
}
|
|
});
|
|
const localSurfaces = new LocalSurfaces({ host, changed: render, createFrame(entry) {
|
|
const frame = document.createElement('iframe'); frame.title = entry.descriptor.label;
|
|
// Like the control frame, append only after assigning src.
|
|
queueMicrotask(() => {
|
|
if (entry.frame === frame) surfaceRows.get(entry.descriptor.id)?.pane.append(frame);
|
|
});
|
|
return frame;
|
|
} });
|
|
new MutationObserver(() => localSurfaces.sweep()).observe(byId('surfaces'), { childList: true, subtree: true });
|
|
window.addEventListener('pagehide', () => connection.disconnect());
|
|
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();
|
|
if (!host.catalog.length) element('p', 'Connect an exhibit to discover its controls.', byId('catalog'));
|
|
const groups = new Map();
|
|
for (const target of host.catalog) {
|
|
const category = target.category || 'Other';
|
|
if (!groups.has(category)) {
|
|
const group = element('div', undefined, byId('catalog')); group.className = 'target-group';
|
|
element('h3', category, group); groups.set(category, group);
|
|
}
|
|
const section = element('article', undefined, groups.get(category)); 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 buildSurfaces() {
|
|
// Preserve live panes across registry refreshes; replacing their ancestors
|
|
// would unload the documents even if their descriptor URLs did not change.
|
|
const container = byId('surfaces');
|
|
for (const [id, row] of surfaceRows) if (!localSurfaces.entries.has(id)) {
|
|
row.card.remove(); surfaceRows.delete(id);
|
|
}
|
|
container.querySelectorAll(':scope > p').forEach(node => node.remove());
|
|
byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : '';
|
|
if (!host.surfaces.length) {
|
|
element('p', 'No presentation surfaces advertised.', container);
|
|
return;
|
|
}
|
|
for (const surface of host.surfaces) {
|
|
let row = surfaceRows.get(surface.id);
|
|
if (!row) {
|
|
const card = element('article', undefined, container); card.className = 'surface';
|
|
const metadata = element('div', undefined, card);
|
|
const status = element('p', '', card); status.setAttribute('role', 'status');
|
|
const controls = element('div', undefined, card); controls.className = 'controls';
|
|
const buttons = {};
|
|
for (const [label, method] of [['Open', 'open'], ['Reload', 'reload'], ['Close', 'close']]) {
|
|
const button = element('button', label, controls); buttons[method] = button;
|
|
button.addEventListener('click', () => localSurfaces[method](surface.id));
|
|
}
|
|
const pane = element('div', undefined, card);
|
|
row = { card, metadata, status, buttons, pane };
|
|
surfaceRows.set(surface.id, row);
|
|
}
|
|
row.metadata.replaceChildren();
|
|
element('h3', surface.label, row.metadata);
|
|
element('code', surface.id, row.metadata);
|
|
const badges = element('div', undefined, row.metadata);
|
|
const b = element('span', surface.primary ? 'Primary' : 'Non-primary', badges); b.className = 'surface-badge';
|
|
if (surface.role) { const b = element('span', `Role: ${surface.role}`, badges); b.className = 'surface-badge'; }
|
|
if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; }
|
|
const meta = [];
|
|
if (surface.description) meta.push(surface.description);
|
|
if (surface.url) meta.push(`URL: ${surface.url}`);
|
|
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
|
|
if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`);
|
|
if (meta.length) { const p = element('p', meta.join(' · '), row.metadata); p.className = 'surface-meta'; }
|
|
}
|
|
}
|
|
function render() {
|
|
byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`;
|
|
byId('status').dataset.state = host.status;
|
|
byId('identity').textContent = host.exhibit
|
|
? `${host.exhibit.product} · ${host.exhibit.version ?? ''} · Contract ${host.contract.major}.${host.contract.minor}`
|
|
: 'No exhibit connected. Enter a served path to begin.';
|
|
byId('loaded-url').textContent = connection.url ? `Exhibit location: ${connection.url}` : '';
|
|
byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit,
|
|
registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence });
|
|
byId('capabilities').replaceChildren();
|
|
if (!host.capabilities.length) element('p', host.sessionId ? 'No capabilities declared.' : 'Capabilities appear after discovery.', byId('capabilities'));
|
|
for (const capability of host.capabilities) element('li', `${capability.id}: ${capability.state}`, byId('capabilities'));
|
|
byId('state').textContent = json(Object.fromEntries(host.values));
|
|
byId('events').textContent = json(host.eventLog);
|
|
byId('errors').textContent = json(host.logs);
|
|
const latest = host.logs.findLast(entry => entry.code !== 'SESSION');
|
|
byId('latest-error').textContent = latest ? `Latest diagnostic (${latest.time}): ${latest.code}: ${latest.message}` : '';
|
|
byId('latest-error').hidden = !latest;
|
|
byId('target-count').textContent = `(${host.catalog.length})`;
|
|
byId('refresh').disabled = host.status !== 'connected';
|
|
byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating';
|
|
byId('disconnect').disabled = !connection.frame;
|
|
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
|
|
if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); }
|
|
for (const [id, row] of surfaceRows) {
|
|
const entry = localSurfaces.entries.get(id);
|
|
const state = entry?.state ?? 'unavailable';
|
|
const requirements = (Array.isArray(entry?.descriptor.requires) ? entry.descriptor.requires : [])
|
|
.filter(id => host.capabilities.find(c => c.id === id)?.state !== 'ready');
|
|
row.status.textContent = state === 'control'
|
|
? 'Open in the authoritative control frame. Use Connection controls to reload or disconnect.'
|
|
: `${state}${entry?.error ? ': ' + entry.error : ''}${requirements.length ? ' · Unavailable: ' + requirements.join(', ') : ''}`;
|
|
row.buttons.open.disabled = !entry || ['control', 'open', 'loading'].includes(state) || requirements.length > 0;
|
|
row.buttons.reload.disabled = !entry || ['control', 'closed'].includes(state);
|
|
row.buttons.close.disabled = !entry || ['control', 'closed'].includes(state);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
byId('connection').addEventListener('submit', event => {
|
|
event.preventDefault();
|
|
try {
|
|
connection.load(byId('exhibit-url').value);
|
|
} catch (error) { host.report(error); }
|
|
});
|
|
byId('reconnect').addEventListener('click', () => connection.reconnect());
|
|
byId('disconnect').addEventListener('click', () => connection.disconnect());
|
|
byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
|
|
byId('clear-errors').addEventListener('click', () => { host.logs = []; render(); });
|
|
host.subscribe(render); render();
|