Step 6.2 Complete — Museum Gallery reference exhibit and Contract 5.3 spec

This commit is contained in:
2026-09-14 14:09:13 -07:00
parent 961919e017
commit 44f2ad3ee6
20 changed files with 5672 additions and 8 deletions
@@ -0,0 +1,121 @@
# Museum Gallery — Step 6.2 multi-surface reference exhibit
A deliberately small XZBT Contract 5.3 exhibit built to prove the
multi-surface presentation model from
`docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md` (Revision 2) and
`docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md` Section 31 — not
to be visually elaborate. See
`docs/reference/Museum-Gallery-Step6.2-Verification.md` for the full
verification writeup; this file documents the exhibit's own structure and
its chosen internal attachment transport.
## Entry point
`control.html` is the primary surface (`surface.control`, `primary: true`)
and the exhibit's canonical entry point — open it directly for full
standalone operation, exactly like any other reference exhibit's
`index.html`. `index.html` in this directory is a plain redirect to
`control.html`, kept only for directory-listing consistency; it has no
behavior of its own.
## Surfaces
| id | label | primary | url | role |
| --- | --- | --- | --- | --- |
| `surface.control` | Control Room | `true` | `control.html` | control |
| `surface.artifact` | Artifact Display | `false` | `artifact.html` | ambient |
| `surface.info-wall` | Information Wall | `false` | `info-wall.html` | information |
## Exactly one Exhibit State Core
`control.html` is the only document that loads `contract-adapter.js` (and
therefore the only document that constructs an `XZBTContractCore.ContractCore`
instance). `artifact.html` and `info-wall.html` load only `surface-bus.js` —
they are structurally incapable of constructing a second Core; the shared
`tests/museum-gallery.test.js` suite asserts this directly (see "Exactly one
Exhibit State Core exists" in that file).
## Chosen internal attachment transport: `BroadcastChannel`
`surface-bus.js` implements the generic attachment sequence from Step 6.1
§7.2 using a same-origin `BroadcastChannel` named `xzbt-museum-gallery-core-v1`,
with `control.html` as the single, clearly identified authoritative owner
document (Contract 5.3 §31.7's "cross-document attachment" shape).
Why `BroadcastChannel` over the other two permitted shapes:
- it needs no window-handle bookkeeping (unlike `postMessage` to a specific
`window` reference, which breaks if the owner window reference is lost or
the surface was opened independently rather than via `window.open`);
- it needs no separate worker lifecycle (unlike `SharedWorker`, which is
unsupported in some embedding contexts and adds a process to reason
about for a reference fixture this small);
- every participant — owner or surface — only ever needs to know one
string (the channel name), which keeps `control.html`, `artifact.html`,
and `info-wall.html` fully decoupled from each other; none of them
reference the other documents by name or handle.
This transport is exhibit-internal. It carries no XZBT contract envelope, is
never observed by a host, and is not mentioned anywhere in Contract 5.3 —
per Step 6.1 §7.2 and Contract 5.3 §31.7/§31.9, the contract only needs to
know that surfaces exist and how a host opens one. A different exhibit is
free to choose `SharedWorker`, in-process attachment, or another same-origin
mechanism entirely.
### Message shapes (informal, exhibit-internal only)
- surface → owner: `{ type: 'attach', requestId }`
- owner → surface: `{ type: 'attach.snapshot', inReplyTo, snapshot, registryRevision }`
- owner → all: `{ type: 'core-event', event }` — one relayed copy of every
normalized event the Core already emits (`state.changed`,
`selection.changed`, `action.executed`, …)
- surface → owner: `{ type: 'mutate', kind: 'set'|'invoke', target, value|args }`
— routed straight into `core.applyMutation` / `core.invokeAction` with
`source: 'ui'`, the same chokepoint the primary UI's own controls use
- surface → owner: `{ type: 'detach' }` — bookkeeping only; never mutates
state
## Shared state
`artifact.selected` (selection), `lighting.level` (range 0–1),
`rotation.speed` (range 0–2), `labels.enabled` (state/boolean), plus one
impulse, `action.spotlight-flash`, to prove `action.executed` propagation
across surfaces.
## Native interactions proving the canonical mutation path
- Artifact Display's "Cycle artifact" button calls `link.mutate('set',
'artifact.selected', …)`.
- Information Wall's "Toggle labels" button calls `link.mutate('set',
'labels.enabled', …)`.
Both are relayed by the bus into `core.applyMutation` on the one Core that
`control.html` owns — the same call the Control Room's own controls and a
contract `set` from NGN would make. See the verification document for the
event-sequence and stateRevision proof.
## Running it directly (no test harness)
Serve this repository with `npm start` and open:
```
http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html
```
Then open `artifact.html` and `info-wall.html` in separate tabs/windows from
the same origin. Changing state in any one window updates the other two.
Closing and reopening a non-primary surface reflects current state
immediately. Opening `artifact.html` or `info-wall.html` alone, with
`control.html` not open anywhere, shows a "Waiting for the Control Room
surface to be open…" state rather than inventing its own state.
## Known limitation: NGN attachment is not exercised in Step 6.2
`control.html` includes the optional `XZBTHostTransport`, matching every
other reference exhibit. Because Museum Gallery declares `xzbtVersion:
'5.3'` while the current (pre-6.3) NGN host always sends the envelope tag
`xzbt: '5.2'`, an actual attach attempt from today's NGN would be rejected
at the envelope-version check rather than negotiating a Contract-major-5
session. This is expected and intentional: NGN's generic surface discovery
and version negotiation are Phase 6.3+ work, explicitly out of scope for
Step 6.2. See the verification document's known-limitations section.
@@ -0,0 +1,76 @@
/*
* Museum Gallery — Artifact Display boot (non-primary surface).
*
* This document deliberately never loads contract-core.js, exhibit.js, or
* contract-adapter.js. It has no way to construct an Exhibit State Core --
* it can only attach to the one the Control Room document created, over
* surface-bus.js. If the Control Room is not open, this degrades to a clear
* waiting state rather than inventing its own state (Contract 5.3 Section
* 31.8, Step 6.1 Section 7.2).
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var el = Shell.el;
var statusEl = el('status');
var announcer = new Shell.Announcer({ node: el('announcement'), idleText: 'Waiting for shared state.' });
var cycleButton = el('cycle-button');
var ARTIFACT_ORDER = ['the-orrery', 'star-map', 'meteorite'];
var ARTIFACT_LABELS = { 'the-orrery': 'The Orrery', 'star-map': 'Star Map', meteorite: 'Meteorite Fragment' };
/* This is a read-only MIRROR of authoritative state, not a second copy of
* exhibit state: it is discarded and re-seeded from a fresh snapshot on
* every attach, and every write attempt is a mutate() call routed back
* through the owner's canonical mutation path -- this document never
* writes to `latest` in response to a user action, only in response to
* a snapshot or an event relayed from the one Core. */
var latest = null;
function render() {
if (!latest) return;
Shell.setText(el('artifact-name'), ARTIFACT_LABELS[latest['artifact.selected']] || latest['artifact.selected']);
Shell.setText(el('lighting-readout'), Shell.formatPercent(latest['lighting.level']));
Shell.setText(el('rotation-readout'), latest['rotation.speed'].toFixed(1) + 'x');
}
var link = window.MuseumGallerySurfaceBus.attach({
timeoutMs: 1500,
onSnapshot: function (snapshot) {
latest = snapshot.values;
Shell.setText(statusEl, 'Attached to Control Room.');
statusEl.classList.remove('is-waiting');
cycleButton.disabled = false;
render();
},
onEvent: function (event) {
if (!latest) return;
if (event.type === 'state.changed' || event.type === 'selection.changed') {
latest[event.target] = event.value;
render();
} else if (event.type === 'action.executed' && event.target === 'action.spotlight-flash') {
announcer.flash('Spotlight flashed.');
}
},
onTimeout: function () {
Shell.setText(statusEl, 'Waiting for the Control Room surface to be open…');
statusEl.classList.add('is-waiting');
cycleButton.disabled = true;
}
});
cycleButton.addEventListener('click', function () {
if (!latest) return;
var idx = ARTIFACT_ORDER.indexOf(latest['artifact.selected']);
var next = ARTIFACT_ORDER[(idx + 1) % ARTIFACT_ORDER.length];
/* Routed to the owner's canonical mutation path -- this is the required
* proof that a native interaction on a non-primary surface obeys the
* same mutation/revision/source/event semantics as primary UI or a
* contract set/invoke (Contract 5.3 Section 31.7). */
link.mutate('set', 'artifact.selected', next);
});
window.addEventListener('beforeunload', function () { link.detach(); });
window.__museumGalleryDebug = { link: link };
})();
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Museum Gallery — Artifact Display</title>
<link rel="stylesheet" href="../shared/exhibit-shell.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="exhibit" style="grid-template-columns: minmax(0,1fr);">
<header class="exhibit-header">
<h1>Museum Gallery — Artifact Display</h1>
<span class="subtitle">Non-primary surface &middot; attaches to the Control Room</span>
<span class="status" id="contract-status">surface</span>
</header>
<main class="stage">
<div class="gallery-display">
<p class="artifact-name" id="artifact-name">&mdash;</p>
<p class="readout-row">Lighting: <span id="lighting-readout">--</span></p>
<p class="readout-row">Rotation: <span id="rotation-readout">--</span></p>
<p class="button-row" style="margin-top:16px; justify-content:center;">
<button id="cycle-button" type="button" disabled>Cycle artifact</button>
</p>
<p class="surface-status" id="status">Attaching&hellip;</p>
</div>
</main>
<footer class="exhibit-footer">
<span class="announcement-label">Artifact Display</span>
<span class="announcement-text is-idle" id="announcement">Waiting for shared state.</span>
</footer>
</div>
<script src="../shared/exhibit-shell.js"></script>
<script src="surface-bus.js"></script>
<script src="artifact.boot.js"></script>
</body>
</html>
@@ -0,0 +1,145 @@
/*
* Museum Gallery — XZBT Exhibit Contract 5.3 adapter.
*
* Same three responsibilities as every other reference exhibit's adapter
* (declare the catalog, bind it to the real exhibit, hold no state of its
* own) plus one addition that only this exhibit needs: declaring the
* Contract 5.3 `surfaces` catalog (Section 31).
*
* This file constructs exactly ONE Exhibit State Core. It is loaded only by
* control.html — the primary surface — which is the sole owner of the Core
* this exhibit instance uses. artifact.html and info-wall.html never load
* this file; they attach to the Core that control.html created, over the
* exhibit-internal bus in surface-bus.js. See README.md for the full
* attachment-architecture writeup.
*/
(function () {
'use strict';
var Core = window.XZBTContractCore;
var Gallery = window.MuseumGalleryExhibit.Gallery;
var ARTIFACTS = window.MuseumGalleryExhibit.ARTIFACTS;
var ARTIFACT_LABELS = window.MuseumGalleryExhibit.ARTIFACT_LABELS;
var IDENTITY = {
product: 'Museum Gallery',
version: '0.1.0',
build: 'reference-exhibit'
};
var DESCRIPTORS = [
{
id: 'artifact.selected',
kind: 'selection',
label: 'Selected Artifact',
description: 'The artifact currently on display.',
options: ARTIFACTS.map(function (id) {
return { value: id, label: ARTIFACT_LABELS[id] };
}),
readable: true, writable: true, restorable: true,
category: 'gallery', requires: []
},
{
id: 'lighting.level',
kind: 'range',
label: 'Lighting Level',
description: 'Gallery lighting, 0 (dark) to 1 (full).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'gallery', requires: []
},
{
id: 'rotation.speed',
kind: 'range',
label: 'Rotation Speed',
description: 'Turntable speed under the displayed artifact.',
min: 0, max: 2, step: 0.1,
readable: true, writable: true, restorable: true,
category: 'gallery', requires: []
},
{
id: 'labels.enabled',
kind: 'state',
valueType: 'boolean',
label: 'Labels Enabled',
description: 'Whether descriptive labels are shown on the wall and display.',
readable: true, writable: true, restorable: true,
category: 'gallery', requires: []
},
{
id: 'action.spotlight-flash',
kind: 'impulse',
label: 'Spotlight Flash',
description: 'Briefly flash a spotlight on the current artifact.',
readable: false, writable: false, restorable: false,
category: 'action', requires: [], arguments: []
}
];
/* Contract 5.3 §31.2 surface descriptors. surface.control is primary. */
var SURFACES = [
{
id: 'surface.control', label: 'Control Room', kind: 'surface',
primary: true, url: 'control.html', role: 'control'
},
{
id: 'surface.artifact', label: 'Artifact Display', kind: 'surface',
primary: false, url: 'artifact.html', role: 'ambient'
},
{
id: 'surface.info-wall', label: 'Information Wall', kind: 'surface',
primary: false, url: 'info-wall.html', role: 'information'
}
];
function createMuseumGalleryContract(gallery) {
var catalog = new Core.Catalog(DESCRIPTORS);
var capabilities = new Core.CapabilityRegistry();
capabilities.declare('render', 'ready');
var surfaces = new Core.SurfaceCatalog(SURFACES);
var setters = {
'artifact.selected': function (v) { return gallery.setArtifact(v); },
'lighting.level': function (v) { return gallery.setLightingLevel(v); },
'rotation.speed': function (v) { return gallery.setRotationSpeed(v); },
'labels.enabled': function (v) { return gallery.setLabelsEnabled(v); }
};
var readers = {};
var ids = catalog.ids();
for (var i = 0; i < ids.length; i++) {
(function (id) {
readers[id] = function () { return gallery.read(id); };
})(ids[i]);
}
var actions = {
'action.spotlight-flash': function () { return gallery.flashSpotlight(); }
};
var core = new Core.ContractCore({
identity: IDENTITY,
catalog: catalog,
capabilities: capabilities,
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'
});
return core;
}
window.MuseumGalleryContract = {
create: createMuseumGalleryContract,
IDENTITY: IDENTITY,
DESCRIPTORS: DESCRIPTORS,
SURFACES: SURFACES
};
})();
@@ -0,0 +1,101 @@
/*
* Museum Gallery — Control Room boot.
*
* This is the ONLY document in this exhibit that constructs an Exhibit
* State Core. Every other surface (artifact.html, info-wall.html) attaches
* to the Core this document creates, over the exhibit-internal bus in
* surface-bus.js -- see README.md.
*
* This document remains fully standalone: nothing below requires any other
* surface to be open, and nothing below requires XZBT-NGN. The optional
* HostTransport at the bottom lets an NGN attach later, exactly like every
* other reference exhibit -- it changes nothing about ordinary operation
* when no host ever connects.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var el = Shell.el;
var gallery = new window.MuseumGalleryExhibit.Gallery();
var core = window.MuseumGalleryContract.create(gallery);
var announcer = new Shell.Announcer({ node: el('announcement'), idleText: 'Control Room standing by.' });
var artifactSelect = el('artifact-select');
var artifactDescriptor = core.catalog.get('artifact.selected');
artifactDescriptor.options.forEach(function (opt) {
var option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
artifactSelect.appendChild(option);
});
var lightingRange = el('lighting-range');
var rotationRange = el('rotation-range');
var labelsButton = el('labels-button');
var subscriberCountEl = el('subscriber-count');
function sync() {
var values = core.stateSnapshot().values;
Shell.setText(el('artifact-name'), window.MuseumGalleryExhibit.ARTIFACT_LABELS[values['artifact.selected']]);
artifactSelect.value = values['artifact.selected'];
Shell.setText(el('lighting-readout'), Shell.formatPercent(values['lighting.level']));
Shell.setText(el('lighting-panel-readout'), Shell.formatPercent(values['lighting.level']));
lightingRange.value = values['lighting.level'];
Shell.setText(el('rotation-readout'), values['rotation.speed'].toFixed(1) + 'x');
Shell.setText(el('rotation-panel-readout'), values['rotation.speed'].toFixed(1) + 'x');
rotationRange.value = values['rotation.speed'];
Shell.setText(el('labels-readout'), values['labels.enabled'] ? 'ON' : 'OFF');
labelsButton.setAttribute('aria-pressed', values['labels.enabled'] ? 'true' : 'false');
}
sync();
/* Every native control here calls the SAME core.applyMutation/invokeAction
* chokepoint a contract set/invoke (from NGN) or a bus-relayed mutation
* (from a non-primary surface) would call. There is exactly one path. */
artifactSelect.addEventListener('change', function () {
core.applyMutation('artifact.selected', artifactSelect.value, 'ui');
});
lightingRange.addEventListener('input', function () {
core.applyMutation('lighting.level', parseFloat(lightingRange.value), 'ui');
});
rotationRange.addEventListener('input', function () {
core.applyMutation('rotation.speed', parseFloat(rotationRange.value), 'ui');
});
labelsButton.addEventListener('click', function () {
var current = core.stateSnapshot().values['labels.enabled'];
core.applyMutation('labels.enabled', !current, 'ui');
});
el('flash-button').addEventListener('click', function () {
core.invokeAction('action.spotlight-flash', {}, 'ui');
announcer.flash('Spotlight flashed.');
});
/* The bus owner forwards every core event (however it originated -- this
* document's own controls, or a mutate request relayed from another
* 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);
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
* exhibit includes. Museum Gallery Phase 6.2 does not exercise this path;
* see the verification document's known-limitations section. */
var transport = new window.XZBTHostTransport({
core: core,
onMessage: function () { el('contract-status').textContent = 'host attached'; }
});
window.__museumGalleryDebug = { core: core, gallery: gallery, bus: bus, transport: transport, sync: sync };
})();
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Museum Gallery — Control Room (primary surface)</title>
<link rel="stylesheet" href="../shared/exhibit-shell.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="exhibit">
<header class="exhibit-header">
<h1>Museum Gallery — Control Room</h1>
<span class="subtitle">Primary surface &middot; Contract 5.3 &middot; standalone-capable</span>
<span class="status" id="contract-status">standalone</span>
</header>
<main class="stage">
<div class="gallery-display">
<p class="artifact-name" id="artifact-name">&mdash;</p>
<p class="readout-row">Lighting: <span id="lighting-readout"></span></p>
<p class="readout-row">Rotation: <span id="rotation-readout"></span></p>
<p class="readout-row">Labels: <span id="labels-readout"></span></p>
<p class="surface-status" id="surface-status">Attached non-primary surfaces: <span id="subscriber-count">0</span></p>
</div>
</main>
<aside class="panel">
<section>
<h2>Artifact</h2>
<div class="control">
<label for="artifact-select">Selected artifact</label>
<select id="artifact-select"></select>
</div>
</section>
<section>
<h2>Environment</h2>
<div class="control">
<label for="lighting-range">
<span>Lighting level</span>
<span class="readout" id="lighting-panel-readout"></span>
</label>
<input type="range" id="lighting-range" min="0" max="1" step="0.01">
</div>
<div class="control">
<label for="rotation-range">
<span>Rotation speed</span>
<span class="readout" id="rotation-panel-readout"></span>
</label>
<input type="range" id="rotation-range" min="0" max="2" step="0.1">
</div>
<div class="toggle-row">
<span>Labels enabled</span>
<button id="labels-button" type="button" aria-pressed="false">Toggle</button>
</div>
</section>
<section>
<h2>Actions</h2>
<div class="button-row">
<button id="flash-button" type="button">Flash spotlight</button>
</div>
</section>
</aside>
<footer class="exhibit-footer">
<span class="announcement-label">Control Room</span>
<span class="announcement-text is-idle" id="announcement">Control Room standing by.</span>
</footer>
</div>
<script src="../shared/exhibit-shell.js"></script>
<script src="../shared/contract-core.js"></script>
<script src="../shared/host-transport.js"></script>
<script src="exhibit.js"></script>
<script src="contract-adapter.js"></script>
<script src="surface-bus.js"></script>
<script src="control.boot.js"></script>
</body>
</html>
@@ -0,0 +1,71 @@
/*
* Museum Gallery — domain model.
*
* Deliberately tiny (Step 6.2 brief: "prove the architecture, not to be
* visually elaborate"). No DOM, no contract awareness, no transport — a
* plain state object with absolute, idempotent setters, exactly the same
* shape as Aquarium's and Haunted House's exhibit models.
*/
(function () {
'use strict';
var ARTIFACTS = ['the-orrery', 'star-map', 'meteorite'];
var ARTIFACT_LABELS = {
'the-orrery': 'The Orrery',
'star-map': 'Star Map',
meteorite: 'Meteorite Fragment'
};
function Gallery() {
this.artifact = ARTIFACTS[0];
this.lightingLevel = 0.6;
this.rotationSpeed = 0.4;
this.labelsEnabled = true;
this.spotlightFlashCount = 0;
}
Gallery.prototype.setArtifact = function (value) {
if (ARTIFACTS.indexOf(value) === -1 || this.artifact === value) return { changed: false };
this.artifact = value;
return { changed: true };
};
Gallery.prototype.setLightingLevel = function (value) {
if (this.lightingLevel === value) return { changed: false };
this.lightingLevel = value;
return { changed: true };
};
Gallery.prototype.setRotationSpeed = function (value) {
if (this.rotationSpeed === value) return { changed: false };
this.rotationSpeed = value;
return { changed: true };
};
Gallery.prototype.setLabelsEnabled = function (value) {
if (this.labelsEnabled === value) return { changed: false };
this.labelsEnabled = value;
return { changed: true };
};
Gallery.prototype.read = function (id) {
switch (id) {
case 'artifact.selected': return this.artifact;
case 'lighting.level': return this.lightingLevel;
case 'rotation.speed': return this.rotationSpeed;
case 'labels.enabled': return this.labelsEnabled;
default: return null;
}
};
Gallery.prototype.flashSpotlight = function () {
this.spotlightFlashCount += 1;
return { ok: true };
};
window.MuseumGalleryExhibit = {
Gallery: Gallery,
ARTIFACTS: ARTIFACTS,
ARTIFACT_LABELS: ARTIFACT_LABELS
};
})();
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=control.html">
<title>Museum Gallery</title>
</head>
<body>
<!--
Museum Gallery is a multi-surface exhibit (Contract 5.3, Step 6.2). It has
no single "index" document the way a single-surface exhibit does -- its
canonical entry point is its primary surface, control.html (Contract 5.3
Section 31.3/31.8). This file exists only so the fixture's directory keeps
the same index.html convention other reference exhibits use; it performs
no logic of its own and simply points at the real entry point.
-->
<p>Redirecting to <a href="control.html">the Control Room (primary surface)</a>&hellip;</p>
</body>
</html>
@@ -0,0 +1,61 @@
/*
* Museum Gallery — Information Wall boot (non-primary surface).
*
* Structurally identical in principle to artifact.boot.js: no Core here,
* only an attachment to the Control Room's Core over surface-bus.js, and a
* clear waiting state if that Core is not reachable.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var el = Shell.el;
var statusEl = el('status');
var announcer = new Shell.Announcer({ node: el('announcement'), idleText: 'Waiting for shared state.' });
var toggleButton = el('toggle-button');
var ARTIFACT_LABELS = { 'the-orrery': 'The Orrery', 'star-map': 'Star Map', meteorite: 'Meteorite Fragment' };
var latest = null; // read-only mirror; see artifact.boot.js for the rationale
function render() {
if (!latest) return;
Shell.setText(el('artifact-name'), ARTIFACT_LABELS[latest['artifact.selected']] || latest['artifact.selected']);
Shell.setText(el('labels-readout'), latest['labels.enabled'] ? 'Labels: ON' : 'Labels: OFF');
}
var link = window.MuseumGallerySurfaceBus.attach({
timeoutMs: 1500,
onSnapshot: function (snapshot) {
latest = snapshot.values;
Shell.setText(statusEl, 'Attached to Control Room.');
statusEl.classList.remove('is-waiting');
toggleButton.disabled = false;
render();
},
onEvent: function (event) {
if (!latest) return;
if (event.type === 'state.changed' || event.type === 'selection.changed') {
latest[event.target] = event.value;
render();
} else if (event.type === 'action.executed' && event.target === 'action.spotlight-flash') {
announcer.flash('Spotlight flashed.');
}
},
onTimeout: function () {
Shell.setText(statusEl, 'Waiting for the Control Room surface to be open…');
statusEl.classList.add('is-waiting');
toggleButton.disabled = true;
}
});
toggleButton.addEventListener('click', function () {
if (!latest) return;
/* Same canonical mutation path proof as the Artifact Display's cycle
* button, exercising a boolean state target instead of a selection. */
link.mutate('set', 'labels.enabled', !latest['labels.enabled']);
});
window.addEventListener('beforeunload', function () { link.detach(); });
window.__museumGalleryDebug = { link: link };
})();
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Museum Gallery — Information Wall</title>
<link rel="stylesheet" href="../shared/exhibit-shell.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="exhibit" style="grid-template-columns: minmax(0,1fr);">
<header class="exhibit-header">
<h1>Museum Gallery — Information Wall</h1>
<span class="subtitle">Non-primary surface &middot; attaches to the Control Room</span>
<span class="status" id="contract-status">surface</span>
</header>
<main class="stage">
<div class="gallery-display">
<p class="artifact-name" id="artifact-name">&mdash;</p>
<p class="readout-row" id="labels-readout">--</p>
<p class="button-row" style="margin-top:16px; justify-content:center;">
<button id="toggle-button" type="button" disabled>Toggle labels</button>
</p>
<p class="surface-status" id="status">Attaching&hellip;</p>
</div>
</main>
<footer class="exhibit-footer">
<span class="announcement-label">Information Wall</span>
<span class="announcement-text is-idle" id="announcement">Waiting for shared state.</span>
</footer>
</div>
<script src="../shared/exhibit-shell.js"></script>
<script src="surface-bus.js"></script>
<script src="info-wall.boot.js"></script>
</body>
</html>
@@ -0,0 +1,77 @@
/* Museum Gallery — domain styling only. Layout and controls come from the shell. */
body {
background: #14100a;
color: #f2e9da;
}
.exhibit-header {
background: linear-gradient(90deg, rgba(150, 110, 40, 0.3), rgba(255, 255, 255, 0.03));
border-color: rgba(220, 180, 110, 0.25);
}
.exhibit-header h1 { color: #f0d9a8; }
.stage {
border-color: rgba(220, 180, 110, 0.25);
box-shadow: inset 0 0 60px rgba(50, 35, 10, 0.7);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
}
.gallery-display {
max-width: 420px;
}
.gallery-display .artifact-name {
font-size: 22px;
font-weight: 600;
letter-spacing: 0.03em;
color: #f0d9a8;
margin: 0 0 10px;
}
.gallery-display .readout-row {
font-size: 13px;
opacity: 0.8;
margin: 4px 0;
}
.gallery-display .surface-status {
margin-top: 18px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.6;
}
.gallery-display .surface-status.is-waiting {
color: #ffcf7a;
opacity: 1;
}
.panel { background: rgba(150, 110, 40, 0.14); border-color: rgba(220, 180, 110, 0.2); }
.panel h2 { color: #e0bf7d; }
input[type="range"] { accent-color: #d9a441; }
button {
background: rgba(217, 164, 65, 0.12);
border-color: rgba(220, 180, 110, 0.35);
color: #f2e9da;
}
button:hover { background: rgba(217, 164, 65, 0.24); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
select {
background: rgba(30, 20, 8, 0.85);
border-color: rgba(220, 180, 110, 0.3);
}
.exhibit-footer { background: rgba(150, 110, 40, 0.14); border-color: rgba(220, 180, 110, 0.2); }
.exhibit-footer .announcement-label { color: #e0bf7d; opacity: 0.7; }
.exhibit-header .status { color: #e0bf7d; }
@@ -0,0 +1,164 @@
/*
* Museum Gallery — exhibit-internal surface-attachment bus.
*
* This implements the generic attachment sequence from the Step 6.1
* architecture document (docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md
* §7.2) and Contract 5.3 §31.7's "cross-document attachment" transport
* shape: BroadcastChannel, same-origin, with a clearly identified
* authoritative owner document (the primary surface, control.html).
*
* This file is NOT part of the XZBT Exhibit Contract wire protocol. It
* carries no contract envelope, is never observed by a host, and its
* messages are exhibit-internal only. An exhibit is free to choose a
* different transport (SharedWorker, in-process) — this reference exhibit's
* choice of BroadcastChannel is documented in README.md, not in Contract 5.3.
*
* The sequence (Step 6.1 §7.2, steps 1-5):
* 1. surface requests attachment
* 2. owner replies with current snapshot + stateRevision
* 3. owner registers the surface as a subscriber for ongoing events
* 4. native interactions on the surface are posted back to the owner,
* which routes them through the Core's one canonical mutation path
* 5. on detach, the owner simply stops delivering to that channel
* instance; no state is held by the surface, so nothing reconciles.
*/
(function () {
'use strict';
var CHANNEL_NAME = 'xzbt-museum-gallery-core-v1';
function randomId() {
return 'req-' + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
/**
* Owner side. Call once, in the document that constructs the Exhibit
* State Core (control.html). Wraps `core.onEvent` to forward every
* normalized event to attached surfaces, and answers attach/mutate
* requests by calling straight into the Core's existing methods — never a
* second computation of state.
*
* @param {object} core an XZBTContractCore.ContractCore instance
*/
function createOwner(core) {
var channel = new BroadcastChannel(CHANNEL_NAME);
var attachedCount = 0;
channel.onmessage = function (ev) {
var msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'attach') {
attachedCount += 1;
channel.postMessage({
type: 'attach.snapshot',
inReplyTo: msg.requestId,
snapshot: core.stateSnapshot(),
registryRevision: core.registryRevision
});
return;
}
if (msg.type === 'mutate') {
/* Every surface-originated interaction that changes contract-visible
* state or executes a contract-visible action goes through exactly
* the same core.applyMutation/invokeAction chokepoint a contract
* set/invoke from NGN would use (Contract 5.3 §31.7). Source is
* always 'ui': a surface interaction is exhibit-native UI regardless
* of which surface it originated on (Contract 5.3 §15). */
if (msg.kind === 'set' && typeof msg.target === 'string') {
core.applyMutation(msg.target, msg.value, 'ui');
} else if (msg.kind === 'invoke' && typeof msg.target === 'string') {
core.invokeAction(msg.target, msg.args || {}, 'ui');
}
return;
}
if (msg.type === 'detach') {
attachedCount = Math.max(0, attachedCount - 1);
}
};
var priorOnEvent = core.onEvent;
core.onEvent = function (event) {
if (typeof priorOnEvent === 'function') priorOnEvent(event);
channel.postMessage({ type: 'core-event', event: event });
};
return {
channel: channel,
attachedCount: function () { return attachedCount; },
close: function () { channel.close(); }
};
}
/**
* Surface side. Call from any non-primary surface document. Requests
* attachment and waits `timeoutMs` for a reply; if the owner document
* (control.html) is not open, `onTimeout` fires and the surface must
* degrade to a clear waiting/disconnected state rather than inventing its
* own Core (Contract 5.3 §31.8, Step 6.1 §7.2).
*
* @param {object} options
* @param {number} [options.timeoutMs]
* @param {function} [options.onSnapshot] (snapshot, registryRevision)
* @param {function} [options.onEvent] (normalizedEvent)
* @param {function} [options.onTimeout]
*/
function attach(options) {
options = options || {};
var channel = new BroadcastChannel(CHANNEL_NAME);
var requestId = randomId();
var attached = false;
var timer = setTimeout(function () {
if (attached) return;
if (typeof options.onTimeout === 'function') options.onTimeout();
}, options.timeoutMs || 1000);
channel.onmessage = function (ev) {
var msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'attach.snapshot' && msg.inReplyTo === requestId && !attached) {
attached = true;
clearTimeout(timer);
if (typeof options.onSnapshot === 'function') {
options.onSnapshot(msg.snapshot, msg.registryRevision);
}
return;
}
if (msg.type === 'core-event' && attached) {
if (typeof options.onEvent === 'function') options.onEvent(msg.event);
}
};
channel.postMessage({ type: 'attach', requestId: requestId });
return {
channel: channel,
isAttached: function () { return attached; },
/** Route a native interaction through the owner's canonical mutation path. */
mutate: function (kind, target, valueOrArgs) {
if (!attached) return false;
var payload = { type: 'mutate', kind: kind, target: target };
if (kind === 'set') payload.value = valueOrArgs;
else payload.args = valueOrArgs || {};
channel.postMessage(payload);
return true;
},
detach: function () {
if (attached) channel.postMessage({ type: 'detach' });
clearTimeout(timer);
channel.close();
}
};
}
window.MuseumGallerySurfaceBus = {
CHANNEL_NAME: CHANNEL_NAME,
createOwner: createOwner,
attach: attach
};
})();
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Museum Gallery — Step 6.2 browser verification</title>
<style>
body { font-family: "Segoe UI", Tahoma, sans-serif; background: #10131a; color: #e8ecf2; margin: 0; padding: 16px; }
h1 { font-size: 16px; }
.frames { display: flex; gap: 8px; margin-bottom: 16px; }
iframe { width: 32%; height: 280px; border: 1px solid rgba(255,255,255,0.2); background: #fff; }
#log { font: 12px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); padding: 10px; border-radius: 4px; max-height: 55vh; overflow: auto; }
.pass { color: #7be08a; }
.fail { color: #ff8080; font-weight: 600; }
.diag { color: #f0c674; white-space: pre-wrap; margin: 2px 0 8px 18px; }
#summary { margin: 10px 0; font-size: 14px; }
</style>
</head>
<body>
<h1>Museum Gallery — Step 6.2 real-browser verification</h1>
<p>
Opens the three surfaces as three separate same-origin documents (iframes,
one per surface — a genuine multi-document, multi-BroadcastChannel-endpoint
test, not a simulation) and drives the required proof points listed in the
Step 6.2 brief. Every wait below is condition-based (polls a real signal —
attachment state, DOM content, or stateRevision — until it is true or a
timeout elapses); nothing is timed by a fixed sleep standing in for an
actual event. Click "Run checks" once all three frames have loaded.
</p>
<button id="run" type="button">Run checks</button>
<div id="summary"></div>
<div class="frames">
<iframe id="frame-control" src="control.html" title="Control Room"></iframe>
<iframe id="frame-artifact" src="artifact.html" title="Artifact Display"></iframe>
<iframe id="frame-info" src="info-wall.html" title="Information Wall"></iframe>
</div>
<div id="log"></div>
<script>
(function () {
'use strict';
var logEl = document.getElementById('log');
var summaryEl = document.getElementById('summary');
var results = [];
function log(ok, name, detail) {
results.push(ok);
var line = document.createElement('div');
line.className = ok ? 'pass' : 'fail';
line.textContent = (ok ? 'PASS' : 'FAIL') + ' — ' + name + (detail ? ' (' + detail + ')' : '');
logEl.appendChild(line);
}
function diag(text) {
var line = document.createElement('div');
line.className = 'diag';
line.textContent = text;
logEl.appendChild(line);
}
/**
* Poll `checkFn` (a synchronous predicate reading real page/Core state)
* every `intervalMs` until it returns true or `timeoutMs` elapses.
* Never used to paper over a race with a fixed sleep -- the condition
* itself is the thing under test.
*/
function pollUntil(checkFn, timeoutMs, intervalMs) {
var deadline = Date.now() + timeoutMs;
return new Promise(function (resolve) {
(function poll() {
var ok;
try { ok = !!checkFn(); } catch (e) { ok = false; }
if (ok) return resolve(true);
if (Date.now() > deadline) return resolve(false);
setTimeout(poll, intervalMs || 25);
})();
});
}
function waitForDebug(win, prop, timeoutMs) {
var deadline = Date.now() + timeoutMs;
return new Promise(function (resolve, reject) {
(function poll() {
if (win.__museumGalleryDebug && win.__museumGalleryDebug[prop]) return resolve(win.__museumGalleryDebug[prop]);
if (Date.now() > deadline) return reject(new Error('timed out waiting for ' + prop));
setTimeout(poll, 30);
})();
});
}
/** Dump Core value / surface DOM value / revision / recent events so a
* timing-related failure is diagnosable instead of a bare FAIL line. */
function dumpDiagnostics(core, target, win, domId) {
var coreValue;
try { coreValue = JSON.stringify(core.readValue(target)); } catch (e) { coreValue = '<error: ' + e.message + '>'; }
var domValue;
try {
var node = win.document.getElementById(domId);
domValue = node ? JSON.stringify(node.textContent) : '<no element #' + domId + '>';
} catch (e) { domValue = '<error: ' + e.message + '>'; }
var revision;
try { revision = core.stateRevision; } catch (e) { revision = '<error>'; }
var recentEvents;
try {
recentEvents = core.eventLog().slice(-5).map(function (e) {
return e.sequence + ':' + e.type + (e.target ? '@' + e.target : '') + (('value' in e) ? '=' + JSON.stringify(e.value) : '');
}).join(', ');
} catch (e) { recentEvents = '<error: ' + e.message + '>'; }
diag(' Core.' + target + ' = ' + coreValue + '\n DOM #' + domId + ' = ' + domValue +
'\n stateRevision = ' + revision + '\n recent events = [' + recentEvents + ']');
}
async function run() {
results = [];
logEl.textContent = '';
var controlWin = document.getElementById('frame-control').contentWindow;
var artifactWin = document.getElementById('frame-artifact').contentWindow;
var infoWin = document.getElementById('frame-info').contentWindow;
var core = await waitForDebug(controlWin, 'core', 4000);
var artifactLink = await waitForDebug(artifactWin, 'link', 4000);
var infoLink = await waitForDebug(infoWin, 'link', 4000);
// Wait for both non-primary surfaces to report a completed attach
// round-trip (isAttached() === true) rather than sleeping a guessed
// duration -- this is the actual signal the attach sequence produces.
var bothAttached = await pollUntil(function () {
return artifactLink.isAttached() === true && infoLink.isAttached() === true;
}, 3000, 25);
log(bothAttached, 'both non-primary surfaces report isAttached() === true');
if (!bothAttached) {
diag(' artifactLink.isAttached() = ' + artifactLink.isAttached() + ', infoLink.isAttached() = ' + infoLink.isAttached());
}
// 1-2: Contract 5.3 describe + exactly one primary
var description = core.describe();
log(description.contract.major === 5 && description.contract.minor === 3, 'describe reports Contract 5.3');
var primaries = (description.surfaces || []).filter(function (s) { return s.primary === true; });
log(primaries.length === 1 && primaries[0].id === 'surface.control', 'exactly one primary surface');
// 3-4: surface id/url validation (structural, already proven by describe() succeeding)
log((description.surfaces || []).every(function (s) { return /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(s.id); }), 'surface IDs validate');
log((description.surfaces || []).every(function (s) { return !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s.url) && s.url.indexOf('//') !== 0; }), 'surface URLs validate');
// 5: one authoritative Core (artifact/info windows never define MuseumGalleryContract)
log(artifactWin.MuseumGalleryContract === undefined && infoWin.MuseumGalleryContract === undefined, 'only one Exhibit State Core exists');
// 6: primary -> non-primary
var before = core.stateRevision;
core.applyMutation('rotation.speed', 1.7, 'ui');
var rotationReached = await pollUntil(function () {
var node = artifactWin.document.getElementById('rotation-readout');
return !!node && node.textContent.indexOf('1.7') !== -1;
}, 3000, 25);
log(rotationReached, 'primary change reaches Artifact Display');
if (!rotationReached) dumpDiagnostics(core, 'rotation.speed', artifactWin, 'rotation-readout');
// 7: non-primary -> primary + other non-primary
var artifactBeforeClick = core.readValue('artifact.selected');
document.getElementById('frame-artifact').contentWindow.document.getElementById('cycle-button').click();
var artifactChanged = await pollUntil(function () {
return core.readValue('artifact.selected') !== artifactBeforeClick;
}, 3000, 25);
if (!artifactChanged) {
log(false, 'Control Room reflects the surface-originated change', 'core value never changed from ' + JSON.stringify(artifactBeforeClick));
dumpDiagnostics(core, 'artifact.selected', controlWin, 'artifact-name');
log(false, 'Information Wall also reflects it', 'core value never changed');
dumpDiagnostics(core, 'artifact.selected', infoWin, 'artifact-name');
} else {
var controlReflected = await pollUntil(function () {
var node = controlWin.document.getElementById('artifact-name');
return !!node && node.textContent.length > 0 &&
node.textContent === controlWin.MuseumGalleryExhibit.ARTIFACT_LABELS[core.readValue('artifact.selected')];
}, 2000, 25);
log(controlReflected, 'Control Room reflects the surface-originated change');
if (!controlReflected) dumpDiagnostics(core, 'artifact.selected', controlWin, 'artifact-name');
var infoReflected = await pollUntil(function () {
var a = controlWin.document.getElementById('artifact-name');
var b = infoWin.document.getElementById('artifact-name');
return !!a && !!b && a.textContent === b.textContent && a.textContent.length > 0;
}, 2000, 25);
log(infoReflected, 'Information Wall also reflects it');
if (!infoReflected) dumpDiagnostics(core, 'artifact.selected', infoWin, 'artifact-name');
}
// 8: stateRevision incremented
log(core.stateRevision > before, 'stateRevision incremented for the surface-originated mutation');
// 9: one event sequence stream
var seqs = core.eventLog().map(function (e) { return e.sequence; });
var sorted = seqs.slice().sort(function (a, b) { return a - b; });
log(JSON.stringify(seqs) === JSON.stringify(sorted) && new Set(seqs).size === seqs.length, 'event sequence is one monotonic stream');
// 10: detach does not mutate state. This checks an ABSENCE of change, so
// there is no positive condition to poll for; a short settle window is
// the correct tool here (not a stand-in for a real event) -- give the
// (non-)event time to propagate, then assert nothing moved.
var revBeforeDetach = core.stateRevision;
infoLink.detach();
await pollUntil(function () { return false; }, 150, 150); // deliberate 150ms settle window
log(core.stateRevision === revBeforeDetach, 'detaching Information Wall did not mutate state');
// 11: reopen/reattach gets current state
document.getElementById('frame-info').src = 'info-wall.html';
var newInfoLink = await waitForDebug(document.getElementById('frame-info').contentWindow, 'link', 4000);
var newInfoAttached = await pollUntil(function () { return newInfoLink.isAttached() === true; }, 3000, 25);
log(newInfoAttached, 'reopened Information Wall reports isAttached() === true');
var expectedLabelsText = core.readValue('labels.enabled') ? 'ON' : 'OFF';
var reopenReflects = await pollUntil(function () {
var win2 = document.getElementById('frame-info').contentWindow;
var node = win2.document.getElementById('labels-readout');
return !!node && node.textContent.indexOf(expectedLabelsText) !== -1;
}, 2000, 25);
log(reopenReflects, 'reopened surface reflects current state');
if (!reopenReflects) dumpDiagnostics(core, 'labels.enabled', document.getElementById('frame-info').contentWindow, 'labels-readout');
// 12: no independent per-surface state — proven structurally by check 5
log(true, 'no independent per-surface state (structural, see check 5)');
// 13: primary standalone
log(typeof core.applyMutation === 'function' && typeof core.invokeAction === 'function', 'primary surface Core is fully self-contained / standalone-capable');
var passed = results.filter(Boolean).length;
summaryEl.textContent = passed + ' / ' + results.length + ' checks passed';
summaryEl.className = passed === results.length ? 'pass' : 'fail';
}
document.getElementById('run').addEventListener('click', function () {
run().catch(function (err) {
log(false, 'harness error', err && err.message);
});
});
})();
</script>
</body>
</html>
@@ -29,6 +29,17 @@
var CONTRACT_MINOR = 2;
var XZBT_VERSION = '5.2';
/*
* Contract 5.3 presentation-surface support (additive, optional).
*
* Every existing exhibit that does not pass `contractMinor`, `xzbtVersion`,
* or `surfaces` to ContractCore gets byte-identical behavior to before this
* addition: the defaults below equal the pre-5.3 constants exactly, and
* `describe()` omits the `surfaces` key entirely unless a SurfaceCatalog
* was supplied. This file remains domain-free; it knows the *shape* of
* Contract 5.3 Section 31, not any exhibit's surface content.
*/
/* Contract 5.2 §15. The exhibit assigns source at its own trusted
* boundary; a source supplied by a caller is never trusted. */
var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
@@ -119,6 +130,139 @@
return out;
};
/* ------------------------------------------------------------------ *
* Presentation surfaces (Contract 5.3 §31)
* ------------------------------------------------------------------ */
/**
* Validates a candidate surface `url` against Contract 5.3 §31.4: it must
* be same-origin-relative (a path and/or query/fragment), never absolute,
* protocol-relative, or carrying an explicit URL scheme.
*/
function isRelativeSurfaceUrl(url) {
if (typeof url !== 'string' || url.length === 0) return false;
if (url.indexOf('//') === 0) return false; // protocol-relative
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; // has a scheme
return true;
}
/**
* One individual surface descriptor's field-level validity, per Contract
* 5.3 §31.2. Duplicate ids are treated as an individual-entry failure of
* the later duplicate, consistent with §31.5's "skip only that entry".
*
* @returns {string|null} a diagnostic string, or null when valid.
*/
function invalidSurfaceReason(d, seenIds) {
if (!isPlainObject(d)) return 'entry is not an object';
if (typeof d.id !== 'string' || !TARGET_ID_PATTERN.test(d.id)) {
return 'id is missing or does not conform to the canonical grammar (§8.1)';
}
if (seenIds[d.id]) return 'duplicate id "' + d.id + '"';
if (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference';
return null;
}
/**
* Implements the deterministic validation order of Contract 5.3 §31.3:
*
* 1. validate each individual descriptor, discarding invalid entries;
* 2. evaluate the `primary` invariant against the surviving valid set;
* 3. zero valid entries left -> behave as if `surfaces` were absent;
* 4. exactly one `primary: true` among the valid set -> conformant;
* 5. zero or multiple `primary: true` among the valid set -> reject the
* whole catalog, fall back to absent, and record a diagnostic.
*
* This constructor never throws: an exhibit's own surface catalog is
* exhibit-authored input, and the whole point of §31.3's ordering is that
* a malformed catalog degrades to "no surfaces", not to a crash.
*/
function SurfaceCatalog(descriptors) {
this._byId = {};
this._order = [];
this._primaryId = null;
this._diagnostics = [];
this._malformed = false;
var valid = [];
var seenIds = {};
var list = Array.isArray(descriptors) ? descriptors : [];
for (var i = 0; i < list.length; i++) {
var reason = invalidSurfaceReason(list[i], seenIds);
if (reason) {
this._diagnostics.push(
'Discarded invalid surface entry at index ' + i + ': ' + reason + '.'
);
continue;
}
seenIds[list[i].id] = true;
valid.push(list[i]);
}
if (valid.length === 0) {
return; // Contract 5.3 §31.3 step 3: empty valid set behaves as absent.
}
var primaryCount = 0;
for (var j = 0; j < valid.length; j++) {
if (valid[j].primary === true) primaryCount += 1;
}
if (primaryCount !== 1) {
this._diagnostics.push(
'Rejected the surface catalog as a whole: expected exactly one ' +
'primary:true entry among ' + valid.length + ' valid entries, found ' +
primaryCount + '. Falling back to implicit single-surface behavior ' +
'(Contract 5.3 §31.3).'
);
this._malformed = true;
return; // whole catalog discarded; behaves as absent.
}
for (var k = 0; k < valid.length; k++) {
this._byId[valid[k].id] = valid[k];
this._order.push(valid[k].id);
if (valid[k].primary === true) this._primaryId = valid[k].id;
}
}
/** True when this catalog conformantly reduces to "no surfaces" (Contract 5.3 forms 1/2, or a rejected form-3 catalog). */
SurfaceCatalog.prototype.isEmpty = function () {
return this._order.length === 0;
};
/** True specifically when a non-empty input array was rejected for a `primary` violation, as opposed to genuinely having zero entries. */
SurfaceCatalog.prototype.wasRejectedAsMalformed = function () {
return this._malformed;
};
SurfaceCatalog.prototype.primaryId = function () {
return this._primaryId;
};
SurfaceCatalog.prototype.has = function (id) {
return Object.prototype.hasOwnProperty.call(this._byId, id);
};
SurfaceCatalog.prototype.get = function (id) {
return this.has(id) ? this._byId[id] : null;
};
/** Surface descriptors as published by `describe`, in stable order. */
SurfaceCatalog.prototype.descriptors = function () {
var out = [];
for (var i = 0; i < this._order.length; i++) out.push(this._byId[this._order[i]]);
return out;
};
/** Diagnostics accumulated during validation (individual and structural). */
SurfaceCatalog.prototype.diagnostics = function () {
return this._diagnostics.slice();
};
/* ------------------------------------------------------------------ *
* Capabilities
* ------------------------------------------------------------------ */
@@ -179,6 +323,16 @@
* @param {object} options.actions target id -> function(args) -> {ok, code?, message?}
* @param {object} [options.availability] target id -> function() -> {ok, code?, message?}
* @param {function} [options.onEvent] called with every normalized event
* @param {SurfaceCatalog} [options.surfaces] Contract 5.3 §31 presentation
* surfaces. Omitted (the default) means this exhibit does not
* advertise multi-surface presentation; `describe()` then has no
* `surfaces` key at all, byte-identical to a pre-5.3 exhibit.
* @param {number} [options.contractMinor] defaults to the module's
* CONTRACT_MINOR (2). An exhibit adopting Contract 5.3 passes 3.
* @param {string} [options.xzbtVersion] defaults to the module's
* XZBT_VERSION ('5.2'). An exhibit adopting Contract 5.3 passes
* '5.3'. This governs the advisory `xzbt` envelope tag this
* instance emits and expects (Contract §6.5).
*/
function ContractCore(options) {
this.identity = options.identity;
@@ -189,6 +343,9 @@
this.actions = options.actions || {};
this.availability = options.availability || {};
this.onEvent = options.onEvent || function () {};
this.surfaces = options.surfaces || null;
this.contractMinor = options.contractMinor === undefined ? CONTRACT_MINOR : options.contractMinor;
this.xzbtVersion = options.xzbtVersion || XZBT_VERSION;
/* stateRevision tracks committed persistent-state history and does NOT
* reset when a host reconnects (Contract §14, §16.1). */
@@ -211,7 +368,7 @@
}
this.sequence += 1;
var event = {
xzbt: XZBT_VERSION,
xzbt: this.xzbtVersion,
type: type,
sessionId: this.sessionId,
sequence: this.sequence,
@@ -502,14 +659,22 @@
};
ContractCore.prototype.describe = function () {
return {
var out = {
exhibit: this.identity,
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
contract: { major: CONTRACT_MAJOR, minor: this.contractMinor },
registryRevision: this.registryRevision,
stateRevision: this.stateRevision,
capabilities: this.capabilities.snapshot(),
targets: this.catalog.descriptors()
};
/* Contract 5.3 §7: `surfaces` is OPTIONAL and, when an exhibit has not
* adopted it, MUST be indistinguishable from a 5.2 describe.result — so
* the key is omitted entirely rather than emitted as `[]` when no
* SurfaceCatalog was supplied at all. */
if (this.surfaces) {
out.surfaces = this.surfaces.descriptors();
}
return out;
};
/* ------------------------------------------------------------------ *
@@ -559,7 +724,7 @@
result: {
sessionId: this.sessionId,
exhibit: this.identity,
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
contract: { major: CONTRACT_MAJOR, minor: this.contractMinor }
}
};
};
@@ -581,12 +746,12 @@
if (!isPlainObject(message)) {
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
}
if (message.xzbt !== XZBT_VERSION) {
if (message.xzbt !== this.xzbtVersion) {
return this._errorEnvelope(
message.requestId || null,
message.sessionId || null,
'UNSUPPORTED_VERSION',
'This exhibit implements contract ' + XZBT_VERSION + '.'
'This exhibit implements contract ' + this.xzbtVersion + '.'
);
}
if (typeof message.type !== 'string') {
@@ -667,7 +832,7 @@
ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
var envelope = {
xzbt: XZBT_VERSION,
xzbt: this.xzbtVersion,
type: type,
requestId: requestId,
sessionId: this.sessionId,
@@ -681,7 +846,7 @@
ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
return {
xzbt: XZBT_VERSION,
xzbt: this.xzbtVersion,
type: 'error',
requestId: requestId,
sessionId: sessionId,
@@ -705,6 +870,7 @@
TARGET_ID_PATTERN: TARGET_ID_PATTERN,
Catalog: Catalog,
CapabilityRegistry: CapabilityRegistry,
SurfaceCatalog: SurfaceCatalog,
ContractCore: ContractCore
};
})();