Step 6.3 Complete — Generic Contract 5.3 Surface Discovery

- src/validation.js: add validateSurfaceCatalog() implementing Contract
  5.3 §§31.2-31.5 normative validation order (validate individuals ->
  discard invalids -> evaluate primary invariant against working set)
- src/host.js: emit xzbt:5.3 advisory (§6.5); store contractMinor;
  add surfaces=[] to resetView(); parse surfaces in refresh(true)
- src/ui.js: add buildSurfaces() for generic descriptor-driven surface
  display; integrate renderedSurfaces tracking into render()
- public/index.html: subtitle updated to Contract 5.2/5.3; add
  surfaces-section with surfaces div
- public/style.css: add .surface, .surface-badge, .surface-meta styles
- tests/surface-validation.test.js: 45 new tests covering all Part 8
  malformed surface cases, lifecycle, registry refresh, 5.2 regression
- docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md

Test results: 88/88 pass (43 pre-existing + 45 new)
Genericity scan: zero matches in src/, public/, server/
git diff --check: clean
This commit is contained in:
2026-09-14 14:19:57 -07:00
parent 44f2ad3ee6
commit dcae56a4b6
7 changed files with 910 additions and 4 deletions
+25
View File
@@ -8,6 +8,7 @@ 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 connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
createFrame() {
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
@@ -100,6 +101,29 @@ function buildCatalog() {
rows.set(target.id, row);
}
}
function buildSurfaces() {
const container = byId('surfaces'); container.replaceChildren();
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);
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'; }
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(' · '), card); p.className = 'surface-meta'; }
}
}
function render() {
byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`;
byId('status').dataset.state = host.status;
@@ -123,6 +147,7 @@ function render() {
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 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)) {