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
@@ -71,6 +71,16 @@
link.mutate('set', 'artifact.selected', next);
});
/* `beforeunload` is best-effort only -- it does not reliably fire when
* NGN removes/replaces this frame's iframe element directly (as opposed
* to a real navigation), so it is not the correctness mechanism here.
* `__xzbtSurfaceDispose` is a well-known, optional hook: local-surfaces.js
* calls it synchronously, if present, on this frame's contentWindow right
* before removing the iframe -- deterministic because it is a direct
* same-origin call, not a queued postMessage that a doomed frame might
* never get to process. detach() is idempotent, so it is safe for both
* this hook and beforeunload to fire (or neither, or either alone). */
window.__xzbtSurfaceDispose = function () { link.detach(); };
window.addEventListener('beforeunload', function () { link.detach(); });
window.__museumGalleryDebug = { link: link };
})();
@@ -126,11 +126,12 @@
setters: setters,
readers: readers,
actions: actions,
surfaces: surfaces,
/* Contract 5.3 adoption is per-instance; every other reference exhibit
* keeps the shared core's Contract 5.2 defaults untouched. */
contractMinor: 3,
xzbtVersion: '5.3'
/* contractMinor/xzbtVersion are intentionally omitted: the shared
* contract-core.js now defaults every exhibit to Contract 5.3, and
* Museum Gallery has no reason to override that default. `surfaces`
* is the one thing that makes this exhibit's contract usage different
* from the others -- Contract 5.3 §31 presentation surfaces. */
surfaces: surfaces
});
return core;
@@ -81,12 +81,17 @@
* surface) to attached surfaces, and answers their attach requests with a
* fresh snapshot read straight from this Core -- never a second
* computation. */
var bus = window.MuseumGallerySurfaceBus.createOwner(core);
function updateSubscriberCount(count) {
Shell.setText(subscriberCountEl, String(count));
}
var bus = window.MuseumGallerySurfaceBus.createOwner(core, {
onConnectionChange: updateSubscriberCount
});
updateSubscriberCount(bus.attachedCount());
var priorOnEvent = core.onEvent;
core.onEvent = function (event) {
if (typeof priorOnEvent === 'function') priorOnEvent(event);
sync();
Shell.setText(subscriberCountEl, String(bus.attachedCount()));
};
/* Optional NGN attachment -- same optional transport every reference
@@ -56,6 +56,12 @@
link.mutate('set', 'labels.enabled', !latest['labels.enabled']);
});
/* Same rationale as artifact.boot.js: `__xzbtSurfaceDispose` is the
* deterministic path (called synchronously by local-surfaces.js right
* before it removes this frame); `beforeunload` remains only as a
* best-effort fallback for the cases NGN can't foresee (e.g. the tab
* closing on its own). detach() is idempotent either way. */
window.__xzbtSurfaceDispose = function () { link.detach(); };
window.addEventListener('beforeunload', function () { link.detach(); });
window.__museumGalleryDebug = { link: link };
})();
@@ -39,23 +39,42 @@
* second computation of state.
*
* @param {object} core an XZBTContractCore.ContractCore instance
* @param {object} [options]
* @param {function} [options.onConnectionChange] (attachedCount)
*/
function createOwner(core) {
function createOwner(core, options) {
options = options || {};
var channel = new BroadcastChannel(CHANNEL_NAME);
var attachedCount = 0;
/* Participant bookkeeping is identity-based (a Set of live participant
* ids), not a bare counter. This is what makes it idempotent: a
* duplicate `attach` from a still-live participant cannot inflate the
* count, and a `detach` for an id that is not (or no longer) present
* cannot underflow it -- both matter because iframe reload/removal can
* make attach/detach delivery imperfect (see attach()'s participantId
* and local-surfaces.js's pre-removal dispose call). */
var participants = new Set();
function notifyConnectionChange() {
if (typeof options.onConnectionChange === 'function') {
options.onConnectionChange(participants.size);
}
}
channel.onmessage = function (ev) {
var msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'attach') {
attachedCount += 1;
if (typeof msg.participantId === 'string') {
participants.add(msg.participantId);
}
channel.postMessage({
type: 'attach.snapshot',
inReplyTo: msg.requestId,
snapshot: core.stateSnapshot(),
registryRevision: core.registryRevision
});
notifyConnectionChange();
return;
}
@@ -75,7 +94,13 @@
}
if (msg.type === 'detach') {
attachedCount = Math.max(0, attachedCount - 1);
/* A detach with no/unknown participantId (a stray legacy message, a
* duplicate, or one that arrives after that id was already removed)
* is a no-op -- Set.delete() only reports and fires the callback
* when it actually removed something live. */
if (typeof msg.participantId === 'string' && participants.delete(msg.participantId)) {
notifyConnectionChange();
}
}
};
@@ -87,7 +112,7 @@
return {
channel: channel,
attachedCount: function () { return attachedCount; },
attachedCount: function () { return participants.size; },
close: function () { channel.close(); }
};
}
@@ -109,7 +134,14 @@
options = options || {};
var channel = new BroadcastChannel(CHANNEL_NAME);
var requestId = randomId();
/* A stable identity for THIS attachment (this document load, this
* BroadcastChannel instance). A reload creates a brand-new attach() call
* and therefore a brand-new participantId, which is exactly what lets
* the owner's Set-based bookkeeping retire the old identity and adopt
* the new one without the two ever being confused for each other. */
var participantId = randomId();
var attached = false;
var detached = false;
var timer = setTimeout(function () {
if (attached) return;
@@ -134,7 +166,21 @@
}
};
channel.postMessage({ type: 'attach', requestId: requestId });
channel.postMessage({ type: 'attach', requestId: requestId, participantId: participantId });
function detach() {
/* Idempotent: local-surfaces.js's deterministic pre-removal dispose
* call and this document's own best-effort `beforeunload` handler can
* both end up calling detach() for the same attachment. Only the
* first actually posts a message; every call still tears down the
* timer/channel so repeated calls are always safe. */
if (attached && !detached) {
detached = true;
channel.postMessage({ type: 'detach', participantId: participantId });
}
clearTimeout(timer);
channel.close();
}
return {
channel: channel,
@@ -148,11 +194,7 @@
channel.postMessage(payload);
return true;
},
detach: function () {
if (attached) channel.postMessage({ type: 'detach' });
clearTimeout(timer);
channel.close();
}
detach: detach
};
}