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.
This commit is contained in:
2026-09-14 19:45:27 -07:00
parent ed76cf6189
commit 745912e451
40 changed files with 5346 additions and 210 deletions
+53 -8
View File
@@ -2,6 +2,7 @@ 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);
@@ -9,6 +10,7 @@ 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';
@@ -17,6 +19,16 @@ const connection = new ExhibitConnection({ host, base: location.href, transport:
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;
@@ -102,18 +114,39 @@ function buildCatalog() {
}
}
function buildSurfaces() {
const container = byId('surfaces'); container.replaceChildren();
// 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', host.sessionId ? 'No presentation surfaces advertised.' : 'No presentation surfaces advertised.', container);
element('p', 'No presentation surfaces advertised.', container);
return;
}
for (const surface of host.surfaces) {
const card = element('article', undefined, container); card.className = 'surface';
element('h3', surface.label, card);
element('code', surface.id, card);
const badges = element('div', undefined, card);
if (surface.primary) { const b = element('span', 'Primary', badges); b.className = 'surface-badge primary'; }
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 = [];
@@ -121,7 +154,7 @@ function buildSurfaces() {
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(' · '), card); p.className = 'surface-meta'; }
if (meta.length) { const p = element('p', meta.join(' · '), row.metadata); p.className = 'surface-meta'; }
}
}
function render() {
@@ -148,6 +181,18 @@ function render() {
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)) {