generated from Labyricorn/labyricorn-project-template
Step 6.2 Complete — Museum Gallery reference exhibit and Contract 5.3 spec
This commit is contained in:
@@ -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 · 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">—</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…</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 · Contract 5.3 · 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">—</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>…</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 · 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">—</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…</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
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user