Files
XZBT-NGN/src/ui.js
T
Labyricorn 745912e451 Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface
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.
2026-09-14 19:45:27 -07:00

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();