Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface

One commit for the work accumulated in the working tree since Step 6.3,
which had never been split into per-step commits:

- src/local-surfaces.js + src/surface-url.js (new); src/ui.js,
  src/validation.js, src/connection.js and public/index.html updated for
  local-surface hosting and generic surface rendering
- tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7)
  new; connection/museum-gallery/surface-validation suites updated
- reference exhibits: shared/contract-core.js defaults to Contract 5.3
  (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface
  catalog; aquarium/haunted-house/planetarium adapters updated
- SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js,
  Observation-surface boot branch, local-change hooks, view.pillars /
  view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT
- SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no
  longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on
  its presence/type, matching the host's own envelope validation; the
  adapter now advertises contract minor 3 / version 5.3.0, which it already
  implemented via the 5.3 surfaces field. Root cause of the five failing
  postmessage-interop tests (host hello was silently dropped).
- docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs;
  test-fixtures/PROVENANCE.md resync record

Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7);
SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check
clean for changed files; two pre-existing trailing-whitespace lines remain
in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from
the authoritative SciFi source.

Step 6.7 live verification (browser Observation, packaged standalone) is
still pending and is not claimed here.
This commit is contained in:
2026-09-14 19:45:27 -07:00
parent ed76cf6189
commit 745912e451
40 changed files with 5346 additions and 210 deletions
@@ -1,5 +1,5 @@
/*
* Aquarium — XZBT Exhibit Contract 5.2 adapter.
* Aquarium — XZBT Exhibit Contract 5.3 adapter.
*
* This file is the only place in the exhibit that knows the contract exists.
* It does three things and nothing else:
@@ -1,5 +1,5 @@
/*
* Haunted House — XZBT Exhibit Contract 5.2 adapter.
* Haunted House — XZBT Exhibit Contract 5.3 adapter.
*
* This adapter is the one that has to be honest about capabilities. The
* exhibit's audio engine cannot start until the browser has seen a user
@@ -71,6 +71,16 @@
link.mutate('set', 'artifact.selected', next);
});
/* `beforeunload` is best-effort only -- it does not reliably fire when
* NGN removes/replaces this frame's iframe element directly (as opposed
* to a real navigation), so it is not the correctness mechanism here.
* `__xzbtSurfaceDispose` is a well-known, optional hook: local-surfaces.js
* calls it synchronously, if present, on this frame's contentWindow right
* before removing the iframe -- deterministic because it is a direct
* same-origin call, not a queued postMessage that a doomed frame might
* never get to process. detach() is idempotent, so it is safe for both
* this hook and beforeunload to fire (or neither, or either alone). */
window.__xzbtSurfaceDispose = function () { link.detach(); };
window.addEventListener('beforeunload', function () { link.detach(); });
window.__museumGalleryDebug = { link: link };
})();
@@ -126,11 +126,12 @@
setters: setters,
readers: readers,
actions: actions,
surfaces: surfaces,
/* Contract 5.3 adoption is per-instance; every other reference exhibit
* keeps the shared core's Contract 5.2 defaults untouched. */
contractMinor: 3,
xzbtVersion: '5.3'
/* contractMinor/xzbtVersion are intentionally omitted: the shared
* contract-core.js now defaults every exhibit to Contract 5.3, and
* Museum Gallery has no reason to override that default. `surfaces`
* is the one thing that makes this exhibit's contract usage different
* from the others -- Contract 5.3 §31 presentation surfaces. */
surfaces: surfaces
});
return core;
@@ -81,12 +81,17 @@
* surface) to attached surfaces, and answers their attach requests with a
* fresh snapshot read straight from this Core -- never a second
* computation. */
var bus = window.MuseumGallerySurfaceBus.createOwner(core);
function updateSubscriberCount(count) {
Shell.setText(subscriberCountEl, String(count));
}
var bus = window.MuseumGallerySurfaceBus.createOwner(core, {
onConnectionChange: updateSubscriberCount
});
updateSubscriberCount(bus.attachedCount());
var priorOnEvent = core.onEvent;
core.onEvent = function (event) {
if (typeof priorOnEvent === 'function') priorOnEvent(event);
sync();
Shell.setText(subscriberCountEl, String(bus.attachedCount()));
};
/* Optional NGN attachment -- same optional transport every reference
@@ -56,6 +56,12 @@
link.mutate('set', 'labels.enabled', !latest['labels.enabled']);
});
/* Same rationale as artifact.boot.js: `__xzbtSurfaceDispose` is the
* deterministic path (called synchronously by local-surfaces.js right
* before it removes this frame); `beforeunload` remains only as a
* best-effort fallback for the cases NGN can't foresee (e.g. the tab
* closing on its own). detach() is idempotent either way. */
window.__xzbtSurfaceDispose = function () { link.detach(); };
window.addEventListener('beforeunload', function () { link.detach(); });
window.__museumGalleryDebug = { link: link };
})();
@@ -39,23 +39,42 @@
* second computation of state.
*
* @param {object} core an XZBTContractCore.ContractCore instance
* @param {object} [options]
* @param {function} [options.onConnectionChange] (attachedCount)
*/
function createOwner(core) {
function createOwner(core, options) {
options = options || {};
var channel = new BroadcastChannel(CHANNEL_NAME);
var attachedCount = 0;
/* Participant bookkeeping is identity-based (a Set of live participant
* ids), not a bare counter. This is what makes it idempotent: a
* duplicate `attach` from a still-live participant cannot inflate the
* count, and a `detach` for an id that is not (or no longer) present
* cannot underflow it -- both matter because iframe reload/removal can
* make attach/detach delivery imperfect (see attach()'s participantId
* and local-surfaces.js's pre-removal dispose call). */
var participants = new Set();
function notifyConnectionChange() {
if (typeof options.onConnectionChange === 'function') {
options.onConnectionChange(participants.size);
}
}
channel.onmessage = function (ev) {
var msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'attach') {
attachedCount += 1;
if (typeof msg.participantId === 'string') {
participants.add(msg.participantId);
}
channel.postMessage({
type: 'attach.snapshot',
inReplyTo: msg.requestId,
snapshot: core.stateSnapshot(),
registryRevision: core.registryRevision
});
notifyConnectionChange();
return;
}
@@ -75,7 +94,13 @@
}
if (msg.type === 'detach') {
attachedCount = Math.max(0, attachedCount - 1);
/* A detach with no/unknown participantId (a stray legacy message, a
* duplicate, or one that arrives after that id was already removed)
* is a no-op -- Set.delete() only reports and fires the callback
* when it actually removed something live. */
if (typeof msg.participantId === 'string' && participants.delete(msg.participantId)) {
notifyConnectionChange();
}
}
};
@@ -87,7 +112,7 @@
return {
channel: channel,
attachedCount: function () { return attachedCount; },
attachedCount: function () { return participants.size; },
close: function () { channel.close(); }
};
}
@@ -109,7 +134,14 @@
options = options || {};
var channel = new BroadcastChannel(CHANNEL_NAME);
var requestId = randomId();
/* A stable identity for THIS attachment (this document load, this
* BroadcastChannel instance). A reload creates a brand-new attach() call
* and therefore a brand-new participantId, which is exactly what lets
* the owner's Set-based bookkeeping retire the old identity and adopt
* the new one without the two ever being confused for each other. */
var participantId = randomId();
var attached = false;
var detached = false;
var timer = setTimeout(function () {
if (attached) return;
@@ -134,7 +166,21 @@
}
};
channel.postMessage({ type: 'attach', requestId: requestId });
channel.postMessage({ type: 'attach', requestId: requestId, participantId: participantId });
function detach() {
/* Idempotent: local-surfaces.js's deterministic pre-removal dispose
* call and this document's own best-effort `beforeunload` handler can
* both end up calling detach() for the same attachment. Only the
* first actually posts a message; every call still tears down the
* timer/channel so repeated calls are always safe. */
if (attached && !detached) {
detached = true;
channel.postMessage({ type: 'detach', participantId: participantId });
}
clearTimeout(timer);
channel.close();
}
return {
channel: channel,
@@ -148,11 +194,7 @@
channel.postMessage(payload);
return true;
},
detach: function () {
if (attached) channel.postMessage({ type: 'detach' });
clearTimeout(timer);
channel.close();
}
detach: detach
};
}
@@ -1,5 +1,5 @@
/*
* Planetarium — XZBT Exhibit Contract 5.2 adapter.
* Planetarium — XZBT Exhibit Contract 5.3 adapter.
*
* Declares the catalog, binds targets to real exhibit services, and routes
* the native UI through the canonical mutation path. No state of its own.
@@ -2428,3 +2428,30 @@ body.xzbt-tv-aspect .observation-overlay:not(.active){display:none}
@media (max-width:700px){
.watch-experience-wrap{min-width:150px}.watch-experience-help{font-size:.54rem}
}
/* ==========================================================================
Step 6.7B -- Observation surface mode (body.surface-observation).
Applied when this document is booted as the non-authoritative Observation
surface (?surface=observation&xi=...). Presentation-only: additive rules,
nothing above this block is touched. The console DOM (header/main) is
hidden rather than removed -- observation-engine.js keeps binding to it
exactly as it does today, unmodified, per the Step 6.7A design.
========================================================================== */
body.surface-observation > header,
body.surface-observation > main {
display: none !important;
}
body.surface-observation .observation-overlay {
position: fixed;
inset: 0;
z-index: 1;
}
/* Exiting/returning to the console is NGN's job (pane lifecycle), not the
surface's -- these controls would otherwise mutate the *console's* own
view.observation overlay flag from a stray click (report SS7/SS8). */
body.surface-observation #obs-btn-exit,
body.surface-observation #observation-return-pill {
display: none !important;
}
@@ -440,6 +440,8 @@
<script src="js/observation-bezels.js"></script>
<script src="js/observation-engine.js"></script>
<script src="js/control-bus.js"></script>
<script src="js/surface-mode.js"></script>
<script src="js/surface-bus.js"></script>
<script src="js/generative-experience.js"></script>
<script src="js/contract-adapter.js"></script>
<script src="js/app.js"></script>
+315 -97
View File
@@ -5,6 +5,20 @@
*/
document.addEventListener('DOMContentLoaded', () => {
// Step 6.7B: boot-mode resolver (Contract 5.3 SS31.4 query-string surface
// form). 'console' is today's application, byte-for-byte -- the resolver
// returns 'console' for no query string and for any unrecognized
// ?surface= value. 'observation' renders only the Observation viewscreen
// and mirrors an authoritative console instance over an exhibit-internal
// BroadcastChannel; it never constructs a second contract session, audio
// graph or generative engine (see the isConsoleMode guards below).
const xzbtSurface = XZBTSurfaceMode.resolve(window.location);
const isConsoleMode = xzbtSurface.mode !== 'observation';
const xzbtInstanceId = isConsoleMode ? XZBTSurfaceMode.newInstanceId() : xzbtSurface.instanceId;
if (!isConsoleMode) document.body.classList.add('surface-observation');
let surfaceOwner = null; // console mode: bus owner, feeds attached surfaces
let surfaceLink = null; // observation mode: bus attachment to the console
// 1. Initialize Audio Subsystems
const audioManager = new AudioManager();
const fncSystem = new FuturisticNoiceCancellation(audioManager);
@@ -21,7 +35,9 @@ document.addEventListener('DOMContentLoaded', () => {
window.expandedAudio = expandedAudio;
window.whataverseAudio = whataverseAudio;
window.engineTransitions = engineTransitions;
const visualizer = new StarshipVisualizer(audioManager, warpCore);
// Step 6.7B: console-only. Its canvases (spectrum, warp core) are
// console-panel elements; never construct it on the Observation surface.
const visualizer = isConsoleMode ? new StarshipVisualizer(audioManager, warpCore) : null;
const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport);
window.observationEngine = observationEngine;
@@ -97,34 +113,55 @@ document.addEventListener('DOMContentLoaded', () => {
const timerDisplay = document.getElementById('timer-display');
const timerButtons = document.querySelectorAll('.btn-timer');
// XZBT Generative Experience subsystem. Deterministic engine remains authoritative.
const generativeExperience = new XZBTGenerativeExperience({
getUniverse: () => UniverseRegistry[activeUniverseId],
getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null,
isPlaying: () => isPlaying,
isObservation: () => observationActive,
audioManager,
onGenerated: (line, state) => {
const ticker = document.getElementById('observation-ticker-text');
if (ticker && observationActive) ticker.textContent = line.toUpperCase();
window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } }));
}
});
generativeExperience.bindUI();
window.generativeExperience = generativeExperience;
// XZBT Generative Experience subsystem. Deterministic engine remains
// authoritative. Step 6.7B: console-only -- WebLLM/Kokoro must never be
// constructed or prepared on the Observation surface (highest-risk guard
// in this design; see the isConsoleMode guard on prepareExperience() in
// enterObservation() below).
let generativeExperience = null;
if (isConsoleMode) {
generativeExperience = new XZBTGenerativeExperience({
getUniverse: () => UniverseRegistry[activeUniverseId],
getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null,
isPlaying: () => isPlaying,
isObservation: () => observationActive,
audioManager,
onGenerated: (line, state) => {
const ticker = document.getElementById('observation-ticker-text');
const tickerText = line.toUpperCase();
if (ticker && observationActive) ticker.textContent = tickerText;
// Mirror the announcement to any attached Observation surface(s):
// the AI only verbalizes state the authority already decided, so
// this is presentation-only text, not contract state (report SS6).
if (surfaceOwner) surfaceOwner.broadcastPresentation('ticker', { tickerText });
window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } }));
}
});
generativeExperience.bindUI();
window.generativeExperience = generativeExperience;
}
// Forward-declare absolute state setters & helpers for Contract Adapter
let setMasterVolumeDirect, setPlayingDirect, setMutedDirect, setFncEnabledDirect;
let setObservationDirect, setViewportFrameDirect, setAlertActiveDirect;
let setUniverseDirect, selectPresetDirect, getContractStateSnapshot;
// Step 6.7B: console-only. Never constructed on the Observation surface --
// this is what makes "no second contract session" a structural fact.
let controlBus = null;
let contractAdapter = null;
// Semantic control bus & XZBT Contract Adapter
const controlBus = new XZBTControlBus();
// Semantic control bus & XZBT Contract Adapter. Step 6.7B: constructed
// only in console mode -- see the isConsoleMode guard closing after
// generativeExperience.onSpeechStateChanged below.
if (isConsoleMode) {
controlBus = new XZBTControlBus();
window.xzbtControlBus = controlBus;
const contractAdapter = new XZBTContractAdapter({
contractAdapter = new XZBTContractAdapter({
product: 'SciFi-XZBT',
version: '5.2.0',
version: '5.3.0',
surfaces: XZBTSurfaceMode.SURFACES(xzbtInstanceId),
instanceId: xzbtInstanceId,
bindings: {
getUniverse: () => activeUniverseId,
getPreset: () => activePresetId,
@@ -203,7 +240,21 @@ document.addEventListener('DOMContentLoaded', () => {
'fnc.enabled': (v, src) => setFncEnabledDirect(v, src),
'view.observation': (v, src) => setObservationDirect(v, src),
'view.viewport-frame': (v, src) => setViewportFrameDirect(v, src),
'alert.active': (v, src) => setAlertActiveDirect(v, src)
'alert.active': (v, src) => setAlertActiveDirect(v, src),
// Step 6.7B: newly-canonical targets. observation-engine.js keeps
// setPillars(); warp-flight has no engine-side setter (only
// toggleWarpFlight()), so the desired boolean is compared against
// the engine's current flightMode before toggling -- this keeps
// observation-engine.js's own simulation code untouched.
'view.pillars': v => {
if (observationEngine) observationEngine.setPillars(!!v);
},
'view.warp-flight': v => {
if (!observationEngine) return;
const want = !!v;
const isWarp = observationEngine.flightMode === 'warp';
if (want !== isWarp) observationEngine.toggleWarpFlight();
}
},
invokers: {
'speech.say': async args => {
@@ -252,43 +303,130 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
window.xzbtContractAdapter = contractAdapter;
fncSystem.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
generativeExperience.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
generativeExperience.onInvoke = id => contractAdapter.invokeAction(id, {}, 'ui');
generativeExperience.onSpeechStateChanged = state => contractAdapter.updateCapability('speech', state);
observationEngine.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
fncSystem.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui');
generativeExperience.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui');
generativeExperience.onInvoke = id => contractAdapter?.invokeAction(id, {}, 'ui');
generativeExperience.onSpeechStateChanged = state => contractAdapter?.updateCapability('speech', state);
// Step 6.7B: exhibit-internal surface-attachment bus, owner side. Fed
// from the adapter's session-independent onLocalChange/onLocalAction
// hooks (contract-adapter.js SS5.4) rather than contract events, which are
// gated on an NGN session and would go silent standalone.
surfaceOwner = XZBTSurfaceBus.createOwner(contractAdapter, {
channelName: XZBTSurfaceMode.channelName(xzbtInstanceId),
// A surface attaching/detaching can flip observationAudienceActive();
// re-evaluate the scheduler so a pane opened with the console overlay
// off (or the last pane closing with it still off) starts/stops the
// ambient generator correctly (report SS6, SS8's symmetric detach case).
onParticipantsChanged: () => scheduleObservationAmbientActivity()
});
contractAdapter.onLocalChange = (target, value, stateRevision) => {
surfaceOwner.broadcastState(target, value, stateRevision);
};
contractAdapter.onLocalAction = (target, args) => {
surfaceOwner.broadcastAction(target, args);
};
} else {
// Step 6.7B: Observation surface (non-authoritative). Never constructs
// a contract session, control bus, or generative engine; attaches to
// the console instance named by ?xi= and mirrors its state over the
// exhibit-internal bus. `applyPresentationState` is defined further
// down (function declarations hoist; these callbacks only actually run
// once a message arrives, long after the rest of this script has run).
surfaceLink = XZBTSurfaceBus.attach({
channelName: XZBTSurfaceMode.channelName(xzbtInstanceId),
timeoutMs: 1500,
onSnapshot: (values, stateRevision, registryRevision, presentation) => {
Object.keys(values || {}).forEach(target => applyPresentationState(target, values[target]));
if (presentation && typeof presentation.tickerText === 'string' && presentation.tickerText) {
const ticker = document.getElementById('observation-ticker-text');
if (ticker) ticker.textContent = presentation.tickerText;
}
// The surface renders the scene unconditionally while open -- no
// view.observation gating (report SS8: surface existence and the
// console's own overlay flag are deliberately separate concepts).
enterObservation();
},
onChange: (target, value) => applyPresentationState(target, value),
onAction: (target, args) => {
if (target === 'display.ticker' && args && typeof args.text === 'string') {
const ticker = document.getElementById('observation-ticker-text');
if (ticker) ticker.textContent = args.text;
}
},
onPresentation: (kind, payload) => {
if (kind === 'ticker' && typeof payload.tickerText === 'string') {
const ticker = document.getElementById('observation-ticker-text');
if (ticker) ticker.textContent = payload.tickerText;
} else if (kind === 'obs-activity') {
// Render the authority's decision; never independently generate
// one (report SS6's "authority decides, surface renders").
triggerObservationActivity(payload.source || 'ambient');
}
},
onTimeout: () => {
// No console instance open on this channel (or a stale/unknown
// ?xi=). Degrade to a clear waiting state rather than inventing
// local authority (report SS9, Museum's proven behavior).
console.warn('[SciFi-XZBT] Observation surface: no console instance found for xi=' + xzbtInstanceId);
}
});
window.__xzbtSurfaceDispose = () => { if (surfaceLink) surfaceLink.detach(); };
window.addEventListener('beforeunload', () => { if (surfaceLink) surfaceLink.detach(); });
} // end isConsoleMode / else observation-surface attach (control bus, contract adapter, surface bus)
// Step 6.7B: the one dock-interaction chokepoint (already true before this
// step for every control except PILLARS/WARP, now including them). Console
// mode routes into the one mutation chokepoint as before; the Observation
// surface routes the same five interactive controls to the console over the
// bus instead, and drops view.observation (RETURN) locally -- posting it
// would toggle the *console's* own overlay, a cross-surface side effect
// the design explicitly forbids (report SS8). RETURN/exit are additionally
// hidden by CSS (body.surface-observation) as the primary defense.
const XZBT_PRESENTATION_ROUTED_TARGETS = new Set([
'view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected'
]);
observationEngine.onMutation = isConsoleMode
? (id, value) => contractAdapter?.applyMutation(id, value, 'ui')
: (id, value) => {
if (!XZBT_PRESENTATION_ROUTED_TARGETS.has(id)) return;
if (surfaceLink) surfaceLink.mutate('set', id, value);
};
// Step 6.7B: console-only (controlBus/contractAdapter don't exist otherwise).
if (isConsoleMode) {
// Intercept XZBTControlBus calls to route through contractAdapter
const origBusSet = controlBus.set.bind(controlBus);
const origBusTrigger = controlBus.trigger.bind(controlBus);
controlBus.set = function (id, value, source = 'external') {
if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) {
const canonicalId = contractAdapter.legacyIdMap.get(id) || id;
const target = contractAdapter.targets.get(canonicalId);
if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) {
const canonicalId = contractAdapter?.legacyIdMap.get(id) || id;
const target = contractAdapter?.targets.get(canonicalId);
let normVal = value;
if (target && target.kind === 'range') {
if (contractAdapter.legacyIdMap.has(id)) {
if (contractAdapter?.legacyIdMap.has(id)) {
const legacy = controlBus.targets.get(id);
if (legacy?.step) normVal = Math.round(Number(value) / legacy.step) * legacy.step;
if (canonicalId === 'view.activity') normVal = Number(normVal) / 100;
}
}
contractAdapter.applyMutation(canonicalId, normVal, source);
contractAdapter?.applyMutation(canonicalId, normVal, source);
return true;
}
return origBusSet(id, value, source);
};
controlBus.trigger = function (id, source = 'external') {
if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) {
const canonicalId = contractAdapter.legacyIdMap.get(id) || id;
const target = contractAdapter.targets.get(canonicalId);
if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) {
const canonicalId = contractAdapter?.legacyIdMap.get(id) || id;
const target = contractAdapter?.targets.get(canonicalId);
if (target && target.kind === 'state') {
// Toggle state if boolean
const curr = contractAdapter.getContractState()[canonicalId];
contractAdapter.applyMutation(canonicalId, !curr, source);
const curr = contractAdapter?.getContractState()[canonicalId];
contractAdapter?.applyMutation(canonicalId, !curr, source);
return true;
}
contractAdapter.invokeAction(canonicalId, {}, source);
contractAdapter?.invokeAction(canonicalId, {}, source);
return true;
}
return origBusTrigger(id, source);
@@ -331,6 +469,7 @@ document.addEventListener('DOMContentLoaded', () => {
controlBus.register('red-alert', { label: 'Red Alert Toggle', type: 'action' });
controlBus.register('generate-announcement', { label: 'Generate AI Announcement', type: 'action' });
controlBus.register('mute', { label: 'Master Mute', type: 'action' });
} // end isConsoleMode (control bus semantic registration)
// TV presentation controls. The final Cast stream quality remains Chrome-controlled.
@@ -365,7 +504,7 @@ document.addEventListener('DOMContentLoaded', () => {
btn.innerHTML = `<span class="u-icon">${u.icon}</span> <span>${u.name}</span>`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
contractAdapter.applyMutation('universe.selected', u.id, 'ui');
contractAdapter?.applyMutation('universe.selected', u.id, 'ui');
});
universeDropdown.appendChild(btn);
});
@@ -399,6 +538,8 @@ document.addEventListener('DOMContentLoaded', () => {
document.body.className = universe.themeClass;
// Assigning className wholesale drops the TV presentation classes, so re-apply them.
applyTV();
// ...and the Step 6.7B surface-mode class, for the same reason.
if (!isConsoleMode) document.body.classList.add('surface-observation');
headerMatrixLabel.textContent = universe.headerTitle;
// Custom titles per universe
@@ -472,7 +613,7 @@ document.addEventListener('DOMContentLoaded', () => {
soundboardTitle.textContent = conf.soundboard;
// Configure Visualizer mode
visualizer.setMode(universe.visualizer || 'warp-core');
visualizer?.setMode(universe.visualizer || 'warp-core');
// Render Event Buttons & Soundboard
renderUniverseEvents(universe);
@@ -509,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => {
async function invokeConsoleEvent(evt, btn, source) {
if (evt.type === 'alert-red' || evt.type === 'alert-yellow') {
const value = evt.type === 'alert-red' ? 'red' : 'yellow';
return contractAdapter.applyMutation('alert.active', alerts.activeAlert === value ? 'none' : value, source);
return contractAdapter?.applyMutation('alert.active', alerts.activeAlert === value ? 'none' : value, source);
}
const canonical = [...contractAdapter.targets.values()].find(t => t.kind === 'impulse' && t.id.startsWith('event.') && (t.id === `event.${evt.type}` || `btn-${t.id.slice(6)}` === evt.id));
if (canonical) return contractAdapter.invokeAction(canonical.id, {}, source);
const canonical = [...contractAdapter?.targets.values()].find(t => t.kind === 'impulse' && t.id.startsWith('event.') && (t.id === `event.${evt.type}` || `btn-${t.id.slice(6)}` === evt.id));
if (canonical) return contractAdapter?.invokeAction(canonical.id, {}, source);
await audioManager.resume();
handleUniverseEvent(evt.type, btn);
}
@@ -531,7 +672,7 @@ document.addEventListener('DOMContentLoaded', () => {
btn.addEventListener('click', async () => {
const aliases = { 'btn-chirp-single': 'sfx.console-chirp', 'btn-telepathic': 'sfx.telepathic-chime', 'btn-commlock': 'sfx.comlock', 'btn-afterburner-snd': 'sfx.afterburner' };
const canonical = aliases[key.id] || `sfx.${key.id.replace(/^btn-/, '')}`;
if (contractAdapter.targets.has(canonical)) return contractAdapter.invokeAction(canonical, {}, 'ui');
if (contractAdapter?.targets.has(canonical)) return contractAdapter?.invokeAction(canonical, {}, 'ui');
await audioManager.resume();
handleSoundboardTrigger(key.id);
});
@@ -553,7 +694,7 @@ document.addEventListener('DOMContentLoaded', () => {
`;
btn.addEventListener('click', () => {
contractAdapter.applyMutation('preset.selected', preset.id, 'ui');
contractAdapter?.applyMutation('preset.selected', preset.id, 'ui');
});
presetContainer.appendChild(btn);
@@ -763,7 +904,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
btnPlay.addEventListener('click', () => {
contractAdapter.applyMutation('transport.playing', !isPlaying, 'ui');
contractAdapter?.applyMutation('transport.playing', !isPlaying, 'ui');
});
// 6. Master Volume Control System
@@ -800,65 +941,65 @@ document.addEventListener('DOMContentLoaded', () => {
}
function setMasterVolume(val) {
contractAdapter.applyMutation('mix.master', val, 'ui');
contractAdapter?.applyMutation('mix.master', val, 'ui');
}
function toggleMute() {
contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'ui');
contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'ui');
}
sliderMasterVol.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.master', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.master', parseFloat(e.target.value), 'ui');
});
btnMasterMute.addEventListener('click', toggleMute);
btnHeaderMute.addEventListener('click', toggleMute);
btnVolDown.addEventListener('click', () => {
contractAdapter.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'ui');
contractAdapter?.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'ui');
});
btnVolUp.addEventListener('click', () => {
contractAdapter.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'ui');
contractAdapter?.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'ui');
});
volStepButtons.forEach(btn => {
btn.addEventListener('click', () => {
const stepVal = parseFloat(btn.getAttribute('data-vol'));
contractAdapter.applyMutation('mix.master', stepVal, 'ui');
contractAdapter?.applyMutation('mix.master', stepVal, 'ui');
});
});
// 7. Channel Synthesizer Sliders
sliderHullVol.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.hull.level', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.hull.level', parseFloat(e.target.value), 'ui');
});
sliderHullFreq.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.hull.frequency', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.hull.frequency', parseFloat(e.target.value), 'ui');
});
sliderHullCutoff.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.hull.cutoff', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.hull.cutoff', parseFloat(e.target.value), 'ui');
});
sliderWarpVol.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.drive.level', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.drive.level', parseFloat(e.target.value), 'ui');
});
sliderWarpBpm.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.drive.pulse-rate', parseInt(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.drive.pulse-rate', parseInt(e.target.value), 'ui');
});
sliderWarpCarrier.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.drive.carrier', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.drive.carrier', parseFloat(e.target.value), 'ui');
});
sliderAirVol.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.environment.level', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.environment.level', parseFloat(e.target.value), 'ui');
});
sliderAirCutoff.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.environment.cutoff', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.environment.cutoff', parseFloat(e.target.value), 'ui');
});
sliderTelemetryVol.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.telemetry.level', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.telemetry.level', parseFloat(e.target.value), 'ui');
});
sliderTelemetryDensity.addEventListener('input', (e) => {
contractAdapter.applyMutation('mix.telemetry.density', parseFloat(e.target.value), 'ui');
contractAdapter?.applyMutation('mix.telemetry.density', parseFloat(e.target.value), 'ui');
});
// 8. Universe Event Handlers
@@ -872,7 +1013,7 @@ document.addEventListener('DOMContentLoaded', () => {
case 'warp':
case 'quantum':
alerts.synthesizeWarpJump();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'alert-red':
case 'action-stations':
@@ -902,11 +1043,11 @@ document.addEventListener('DOMContentLoaded', () => {
break;
case 'demat':
whataverseAudio.synthesizeDematCycle(4);
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'vortex':
whataverseAudio.synthesizeDematCycle(1);
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'cloister':
if (whataverseAudio.activeCloister) {
@@ -923,7 +1064,7 @@ document.addEventListener('DOMContentLoaded', () => {
case 'epstein':
case 'subdrive':
expandedAudio.synthesizeEpsteinBurn();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'purge':
case 'decompress':
@@ -936,11 +1077,11 @@ document.addEventListener('DOMContentLoaded', () => {
case 'starburst':
case 'biodefense':
expandedAudio.synthesizeStarburst();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'neural':
expandedAudio.synthesizeNeuralBondSwell();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'centrifuge':
case 'astrogator':
@@ -951,7 +1092,7 @@ document.addEventListener('DOMContentLoaded', () => {
break;
case 'ftl-jump':
expandedAudio.synthesizeFtlJump();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'flak':
expandedAudio.synthesizeFlakBarrageBurst();
@@ -960,29 +1101,29 @@ document.addEventListener('DOMContentLoaded', () => {
case 'gravity':
case 'rotation':
expandedAudio.synthesizeSingularityEngage();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'solar':
expandedAudio.synthesizeSolarRoar();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'afterburner':
expandedAudio.synthesizeAfterburner();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'ludicrous':
expandedAudio.synthesizeLudicrousSpeed();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
case 'improbability':
expandedAudio.synthesizeImprobabilityFlip();
visualizer.spawnWarpPulses();
visualizer?.spawnWarpPulses();
break;
default:
telemetry.synthesizeLCARDSingleChirp();
break;
}
generativeExperience.describeExistingEvent(type);
generativeExperience?.describeExistingEvent(type);
}
function handleSoundboardTrigger(id) {
@@ -2675,8 +2816,22 @@ document.addEventListener('DOMContentLoaded', () => {
return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500);
}
// Step 6.7B: an "audience" is the console's own overlay OR at least one
// attached Observation surface. Scheduling/deciding activity uses this;
// *local rendering* still gates on observationActive alone (report SS6's
// audience-scheduler correction -- without it, an audience watching only
// the surface would see a static scene forever).
const observationAudienceActive = () => observationActive || !!(surfaceOwner && surfaceOwner.attachedCount() > 0);
function triggerObservationActivity(source = 'ambient') {
if (!observationActive || observationActivity <= 0.001) return;
if (!observationAudienceActive() || observationActivity <= 0.001) return;
// The authority decides; attached surfaces render the same decision
// rather than independently rolling their own random event (report SS6,
// "Transient activity events are mirrored, not regenerated"). Exact
// visual parameters (which contact, which ring) are NOT byte-identical
// across documents -- see the accepted-limitations note in README.md /
// agents.md; only the "an event happens now" decision is synchronized.
if (isConsoleMode && surfaceOwner) surfaceOwner.broadcastPresentation('obs-activity', { source });
// Telemetry is a synchronization pulse, but the slider controls whether
// that pulse becomes visible. At maximum every telemetry pulse produces
@@ -2713,7 +2868,12 @@ document.addEventListener('DOMContentLoaded', () => {
clearTimeout(observationAmbientTimer);
observationAmbientTimer = null;
}
if (!observationActive || observationActivity <= .01) return;
// The Observation surface never runs its own scheduler -- it only
// renders 'obs-activity' decisions relayed from the authority. Without
// this guard two documents would each roll independent random timers,
// violating "no duplicate random event generation" (report SS6/R3).
if (!isConsoleMode) return;
if (!observationAudienceActive() || observationActivity <= .01) return;
// Quiet = occasional subtle life. Active = a busy generative display.
const slow = 12500;
@@ -2798,7 +2958,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateObservationHudMode();
sliderObservationActivity.addEventListener('input', () => {
contractAdapter.applyMutation('view.activity', Number(sliderObservationActivity.value) / 100, 'ui');
contractAdapter?.applyMutation('view.activity', Number(sliderObservationActivity.value) / 100, 'ui');
});
window.addEventListener('scifi-telemetry-activity', (event) => {
@@ -4419,6 +4579,48 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// Step 6.7B: applies one mirrored contract-state value on the
// Observation surface. Reuses the existing local setters/functions
// directly -- never contractAdapter.applyMutation (there is none here) --
// so this is local *rendering*, not a second computation of authority.
// Targets the renderer doesn't care about (mix.*, fnc.*, transport.muted,
// speech.*) are intentionally ignored.
function applyPresentationState(target, value) {
switch (target) {
case 'universe.selected':
if (value && value !== activeUniverseId) setUniverse(value);
break;
case 'preset.selected':
if (value && value !== activePresetId) selectPreset(value);
break;
case 'view.viewport-frame':
if (observationEngine) observationEngine.setViewport(!!value);
break;
case 'view.pillars':
if (observationEngine) observationEngine.setPillars(!!value);
break;
case 'view.warp-flight': {
if (!observationEngine) break;
const isWarp = observationEngine.flightMode === 'warp';
if (!!value !== isWarp) observationEngine.toggleWarpFlight();
break;
}
case 'alert.active':
if (typeof setAlertActiveDirect === 'function') setAlertActiveDirect(value);
break;
case 'view.activity':
setObservationActivity(Number(value) * 100);
break;
case 'transport.playing':
// Cosmetic only (the "PROCEDURAL AUDIO LINK" status line) -- never
// engages audio on the surface.
isPlaying = !!value;
break;
default:
break; // view.observation, mix.*, fnc.*, speech.*, transport.muted: not rendered here.
}
}
function refreshObservation() {
const universe = UniverseRegistry[activeUniverseId];
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
@@ -4442,18 +4644,29 @@ document.addEventListener('DOMContentLoaded', () => {
function enterObservation() {
if (observationActive) return;
generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err));
// Step 6.7B / R2: the single most important guard in this design.
// prepareExperience() lazily loads WebLLM + Kokoro (agents.md's one
// documented network/import exception); calling it unconditionally
// here would make every opened Observation surface download a
// multi-gigabyte model. Console-only.
if (isConsoleMode) {
generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err));
}
observationActive = true;
observationOverlay.classList.add('active');
observationOverlay.setAttribute('aria-hidden', 'false');
refreshObservation();
resetObservationHudFade();
resetObservationReturnFade();
scheduleObservationAmbientActivity();
if (isConsoleMode) {
// The Observation surface never runs its own scheduler -- it renders
// activity decisions broadcast by the authority (report SS6).
scheduleObservationAmbientActivity();
// Give the display an immediate but restrained sign of life.
if (observationActivity > .08) {
window.setTimeout(() => triggerObservationActivity('ambient'), 700);
// Give the display an immediate but restrained sign of life.
if (observationActivity > .08) {
window.setTimeout(() => triggerObservationActivity('ambient'), 700);
}
}
}
@@ -4502,7 +4715,7 @@ document.addEventListener('DOMContentLoaded', () => {
btnObservation.addEventListener('click', (e) => {
e.stopPropagation();
contractAdapter.applyMutation('view.observation', !observationActive, 'ui');
contractAdapter?.applyMutation('view.observation', !observationActive, 'ui');
});
// Click background to exit, but ignore clicks on the control dock or its buttons
@@ -4510,7 +4723,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.target.closest('#observation-control-dock')) return;
e.preventDefault();
e.stopPropagation();
contractAdapter.applyMutation('view.observation', false, 'ui');
contractAdapter?.applyMutation('view.observation', false, 'ui');
});
const observationReturnPill = document.getElementById('observation-return-pill');
@@ -4518,19 +4731,21 @@ document.addEventListener('DOMContentLoaded', () => {
observationReturnPill.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
contractAdapter.applyMutation('view.observation', false, 'ui');
contractAdapter?.applyMutation('view.observation', false, 'ui');
});
}
// 10. Keyboard Shortcuts
// 10. Keyboard Shortcuts. Step 6.7B: console-only -- a keystroke on the
// Observation surface must not mutate state (report SS7).
if (isConsoleMode) {
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.code === 'Space') {
e.preventDefault();
contractAdapter.applyMutation('transport.playing', !isPlaying, 'hotkey');
contractAdapter?.applyMutation('transport.playing', !isPlaying, 'hotkey');
} else if (e.code === 'KeyM') {
contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey');
contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey');
} else if (e.code === 'KeyW') {
if (observationActive && observationEngine) {
observationEngine.toggleWarpFlight();
@@ -4541,7 +4756,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (primaryEvent) invokeConsoleEvent(primaryEvent, primaryActionBtn, 'hotkey');
} else if (e.code === 'KeyF') {
if (observationActive && observationEngine) {
contractAdapter.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey');
contractAdapter?.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey');
return;
}
} else if (e.code === 'KeyP') {
@@ -4552,7 +4767,7 @@ document.addEventListener('DOMContentLoaded', () => {
} else if (e.code === 'KeyR') {
if (observationActive && observationEngine) {
const nextAlert = alerts.activeAlert === 'red' ? 'none' : 'red';
contractAdapter.applyMutation('alert.active', nextAlert, 'hotkey');
contractAdapter?.applyMutation('alert.active', nextAlert, 'hotkey');
return;
}
const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
@@ -4561,23 +4776,24 @@ document.addEventListener('DOMContentLoaded', () => {
if (alertEvent) invokeConsoleEvent(alertEvent, firstAlertBtn, 'hotkey');
} else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
e.preventDefault();
contractAdapter.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'hotkey');
contractAdapter?.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'hotkey');
} else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
e.preventDefault();
contractAdapter.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'hotkey');
contractAdapter?.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'hotkey');
} else if (e.code >= 'Digit1' && e.code <= 'Digit9') {
const num = parseInt(e.code.replace('Digit', ''));
contractAdapter.applyMutation('mix.master', num * 0.1, 'hotkey');
contractAdapter?.applyMutation('mix.master', num * 0.1, 'hotkey');
} else if (e.code === 'Digit0') {
contractAdapter.applyMutation('mix.master', 1.0, 'hotkey');
contractAdapter?.applyMutation('mix.master', 1.0, 'hotkey');
} else if (e.code === 'Escape') {
if (observationActive) {
contractAdapter.applyMutation('view.observation', false, 'hotkey');
contractAdapter?.applyMutation('view.observation', false, 'hotkey');
return;
}
contractAdapter.applyMutation('alert.active', 'none', 'hotkey');
contractAdapter?.applyMutation('alert.active', 'none', 'hotkey');
}
});
} // end isConsoleMode (keyboard shortcuts)
// --- Implementation of Absolute Setters and Contract Helpers ---
setMasterVolumeDirect = function (val, isMuted = false) {
@@ -4688,6 +4904,8 @@ document.addEventListener('DOMContentLoaded', () => {
'fnc.level': fncSystem ? fncSystem.level : 0.5,
'view.observation': observationActive,
'view.viewport-frame': observationEngine ? observationEngine.showViewport : true,
'view.pillars': observationEngine ? observationEngine.showPillars : false,
'view.warp-flight': observationEngine ? observationEngine.flightMode === 'warp' : false,
'view.activity': observationActivity,
'alert.active': alertStatus,
'universe.selected': activeUniverseId,
@@ -4699,5 +4917,5 @@ document.addEventListener('DOMContentLoaded', () => {
initUniverseDropdown();
setUniverse('starfleet');
updateMasterVolumeUI(0.75, false);
visualizer.init('spectrum-canvas', 'warp-core-canvas');
visualizer?.init('spectrum-canvas', 'warp-core-canvas');
});
@@ -1,5 +1,5 @@
/**
* XZBT Contract Adapter — Specification v5.2
* XZBT Contract Adapter — Specification v5.3
*
* Implements the normalized contract interface for SciFi-XZBT:
* - Canonical external target catalog & descriptors
@@ -15,10 +15,10 @@
class XZBTContractAdapter {
constructor(options = {}) {
this.product = options.product || 'SciFi-XZBT';
this.version = options.version || '5.2.0';
this.version = options.version || '5.3.0';
this.build = options.build || 'production';
this.contractMajor = 5;
this.contractMinor = 2;
this.contractMinor = 3;
this.sessionId = null;
this.sessionActive = false;
@@ -28,6 +28,17 @@
this.operationQueue = Promise.resolve();
this.eventSink = null;
// Surface catalog (Contract 5.3 §31.2/§31.3). Advertised in describe()
// only when non-empty, so absence stays §31.3 form 1 ("absent").
this.surfaces = Array.isArray(options.surfaces) ? options.surfaces : null;
this.instanceId = options.instanceId || null;
// Session-independent local-change hooks (Step 6.7B). Unlike
// _emitEvent, these fire regardless of sessionActive so an
// exhibit-internal surface mirror keeps working with no NGN attached.
this.onLocalChange = typeof options.onLocalChange === 'function' ? options.onLocalChange : null;
this.onLocalAction = typeof options.onLocalAction === 'function' ? options.onLocalAction : null;
// Transport hooks
this.onMessage = options.onMessage || (() => {});
this.hostOrigin = options.hostOrigin || null; // optional origin filter
@@ -355,6 +366,31 @@
requires: ['observation']
});
reg({
id: 'view.pillars',
legacyId: 'observation-pillars',
label: 'Window Pillars / Mullions',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'view.warp-flight',
label: 'Warp Flight Mode',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'alert.active',
label: 'Active Tactical Alert Status',
@@ -416,9 +452,9 @@
restorable: false,
category: 'speech',
requires: ['speech'],
args: {
text: { type: 'string', required: true, maxLength: 2000 }
}
arguments: [
{ name: 'text', type: 'string', required: true, maxLength: 2000 }
]
});
reg({
@@ -442,9 +478,9 @@
restorable: false,
category: 'display',
requires: ['display-text'],
args: {
text: { type: 'string', required: true, maxLength: 512 }
}
arguments: [
{ name: 'text', type: 'string', required: true, maxLength: 512 }
]
});
// --- 4. Canonical Universe Event Impulses ---
@@ -555,7 +591,7 @@
return;
}
// Ignore non-XZBT messages
if (!e.data || typeof e.data !== 'object' || e.data.xzbt !== '5.2') {
if (!e.data || typeof e.data !== 'object' || typeof e.data.xzbt !== 'string') {
return;
}
if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) return;
@@ -616,6 +652,15 @@
stateRevision: this.stateRevision, target: target.id, value: after[target.id]
}, correlationId, source);
}
// Session-independent mirror feed (Step 6.7B). _emitEvent above is
// gated on sessionActive and therefore silent with no NGN attached;
// onLocalChange is not, so an exhibit-internal surface mirror keeps
// working standalone. Fires for every changed target, same as above.
if (this.onLocalChange) {
for (const target of changes) {
this.onLocalChange(target.id, after[target.id], this.stateRevision);
}
}
return true;
}
@@ -755,11 +800,13 @@
// Validate args schema & text length limits if defined
const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {};
if (Object.keys(sanitizedArgs).some(key => !target.args || !target.args[key])) {
const argumentSpecs = target.arguments || [];
if (Object.keys(sanitizedArgs).some(key => !argumentSpecs.some(spec => spec.name === key))) {
return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
}
if (target.args) {
for (const [argKey, argSpec] of Object.entries(target.args)) {
if (argumentSpecs.length) {
for (const argSpec of argumentSpecs) {
const argKey = argSpec.name;
if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
}
@@ -823,6 +870,11 @@
args: sanitizedArgs
}, correlationId, source);
// Session-independent mirror feed (Step 6.7B); see _commitChanges.
if (this.onLocalAction) {
this.onLocalAction(canonicalId, sanitizedArgs);
}
return { ok: true };
}
@@ -836,7 +888,7 @@
// Public API: Protocol Description
describe() {
return {
const description = {
exhibit: {
product: this.product,
version: this.version,
@@ -851,6 +903,10 @@
capabilities: Array.from(this.capabilities.values()),
targets: Array.from(this.targets.values())
};
if (this.surfaces && this.surfaces.length) {
description.surfaces = this.surfaces;
}
return description;
}
// Update Capability state and emit capability.changed
@@ -204,7 +204,12 @@ class ObservationEngine {
if (this.btnWarp) {
this.btnWarp.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleWarpFlight();
// Step 6.7B: route through the canonical view.warp-flight target,
// like btnFrame's view.viewport-frame, instead of mutating engine
// state directly -- this is what lets the Observation surface and
// the console converge through the one mutation chokepoint.
if (this.onMutation) this.onMutation('view.warp-flight', this.flightMode !== 'warp');
else this.toggleWarpFlight();
});
}
@@ -219,7 +224,9 @@ class ObservationEngine {
if (this.btnPillars) {
this.btnPillars.addEventListener('click', (e) => {
e.stopPropagation();
this.togglePillars();
// Step 6.7B: route through the canonical view.pillars target.
if (this.onMutation) this.onMutation('view.pillars', !this.showPillars);
else this.togglePillars();
});
}
@@ -0,0 +1,254 @@
/**
* XZBT Surface Bus — SciFi-XZBT (Step 6.7B)
*
* Exhibit-internal state mirror between the authoritative console document
* and the non-authoritative Observation surface document. Adapted from
* Museum Gallery's proven `surface-bus.js` shape (Steps 6.3-6.6), with one
* deliberate improvement: the BroadcastChannel name is scoped to a single
* console instance id (XZBTSurfaceMode.channelName), so two console tabs on
* one origin cannot cross-talk.
*
* This carries NO XZBT Exhibit Contract envelope — no `xzbt` field, no
* `sessionId` — and is never observed by an NGN host (Contract 5.3 §31.7's
* explicit allowance for exhibit-internal transports). Message shapes:
*
* surface -> owner : { type:'attach', requestId, participantId }
* owner -> surface : { type:'attach.snapshot', inReplyTo, values,
* stateRevision, registryRevision,
* presentation:{ tickerText } }
* owner -> all : { type:'state', target, value, stateRevision }
* owner -> all : { type:'action', target, args }
* owner -> all : { type:'presentation', kind:'ticker'|'obs-activity', ... }
* surface -> owner : { type:'mutate', kind:'set'|'invoke', target, value|args }
* surface -> owner : { type:'detach', participantId }
*/
(function (root) {
'use strict';
function randomId() {
return 'req-' + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
/**
* Owner side. Call once, in the console (authoritative) document, after
* the XZBTContractAdapter has been constructed.
*
* @param {object} adapter an XZBTContractAdapter instance
* @param {object} options
* @param {string} options.channelName instance-scoped channel name
* @param {function} [options.onParticipantsChanged] (attachedCount)
*/
function createOwner(adapter, options) {
options = options || {};
if (!options.channelName) {
throw new Error('surface-bus: createOwner requires options.channelName');
}
var channel = new BroadcastChannel(options.channelName);
var lastPresentation = { tickerText: '' };
// Set-based participant bookkeeping (not a counter): a duplicate/late
// attach or detach can never ratchet the count (Museum's lesson).
var participants = new Set();
var closed = false;
function notifyParticipantsChanged() {
if (typeof options.onParticipantsChanged === 'function') {
options.onParticipantsChanged(participants.size);
}
}
function currentValues() {
var state = adapter.getContractState();
var values = {};
adapter.targets.forEach(function (target, id) {
if (target.readable && target.kind !== 'impulse') {
values[id] = state[id];
}
});
return values;
}
channel.onmessage = function (ev) {
var msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'attach') {
if (typeof msg.participantId === 'string') {
participants.add(msg.participantId);
}
channel.postMessage({
type: 'attach.snapshot',
inReplyTo: msg.requestId,
values: currentValues(),
stateRevision: adapter.stateRevision,
registryRevision: adapter.registryRevision,
presentation: lastPresentation
});
notifyParticipantsChanged();
return;
}
if (msg.type === 'mutate') {
// Every surface-originated interaction goes through exactly the
// same applyMutation/invokeAction chokepoint a contract set/invoke
// from NGN would use. Source is always 'ui'.
if (msg.kind === 'set' && typeof msg.target === 'string') {
adapter.applyMutation(msg.target, msg.value, 'ui');
} else if (msg.kind === 'invoke' && typeof msg.target === 'string') {
adapter.invokeAction(msg.target, msg.args || {}, 'ui');
}
return;
}
if (msg.type === 'detach') {
if (typeof msg.participantId === 'string' && participants.delete(msg.participantId)) {
notifyParticipantsChanged();
}
}
};
return {
channel: channel,
attachedCount: function () { return participants.size; },
/** Broadcast one changed target (fed from adapter.onLocalChange). */
broadcastState: function (target, value, stateRevision) {
if (closed) return;
channel.postMessage({ type: 'state', target: target, value: value, stateRevision: stateRevision });
},
/** Broadcast an executed impulse (fed from adapter.onLocalAction). */
broadcastAction: function (target, args) {
if (closed) return;
channel.postMessage({ type: 'action', target: target, args: args || {} });
},
/** Broadcast a transient presentation-only extra (ticker text, an
* ambient-activity decision). Not contract state. */
broadcastPresentation: function (kind, payload) {
if (closed) return;
var msg = Object.assign({ type: 'presentation', kind: kind }, payload || {});
if (kind === 'ticker' && payload && typeof payload.tickerText === 'string') {
lastPresentation = { tickerText: payload.tickerText };
}
channel.postMessage(msg);
},
detach: function () {
if (closed) return;
closed = true;
channel.close();
}
};
}
/**
* Surface side. Call from the Observation surface document. Requests
* attachment and waits `timeoutMs` for a reply; if no owner document is
* open on the same channel, `onTimeout` fires and the surface must
* degrade to a waiting state rather than inventing its own authority.
*
* @param {object} options
* @param {string} options.channelName instance-scoped channel name
* @param {number} [options.timeoutMs]
* @param {function} [options.onSnapshot] (values, stateRevision, registryRevision, presentation)
* @param {function} [options.onChange] (target, value, stateRevision)
* @param {function} [options.onAction] (target, args)
* @param {function} [options.onPresentation] (kind, payload)
* @param {function} [options.onTimeout]
*/
function attach(options) {
options = options || {};
if (!options.channelName) {
throw new Error('surface-bus: attach requires options.channelName');
}
var channel = new BroadcastChannel(options.channelName);
var requestId = randomId();
// A fresh participantId per attach() call. A reload is a brand-new
// attach() and therefore a brand-new id, which is what lets the
// owner's Set-based bookkeeping retire the old identity cleanly.
var participantId = randomId();
var attachedFlag = false;
var detachedFlag = false;
var timer = setTimeout(function () {
if (attachedFlag) return;
if (typeof options.onTimeout === 'function') options.onTimeout();
}, options.timeoutMs || 1500);
channel.onmessage = function (ev) {
var msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'attach.snapshot' && msg.inReplyTo === requestId && !attachedFlag) {
attachedFlag = true;
clearTimeout(timer);
if (typeof options.onSnapshot === 'function') {
options.onSnapshot(msg.values, msg.stateRevision, msg.registryRevision, msg.presentation);
}
return;
}
if (!attachedFlag) return;
if (msg.type === 'state') {
if (typeof options.onChange === 'function') {
options.onChange(msg.target, msg.value, msg.stateRevision);
}
return;
}
if (msg.type === 'action') {
if (typeof options.onAction === 'function') {
options.onAction(msg.target, msg.args);
}
return;
}
if (msg.type === 'presentation') {
if (typeof options.onPresentation === 'function') {
options.onPresentation(msg.kind, msg);
}
}
};
channel.postMessage({ type: 'attach', requestId: requestId, participantId: participantId });
function detach() {
// Idempotent: local-surfaces.js's pre-removal dispose call and this
// document's own beforeunload handler can both fire for the same
// attachment. Only the first actually posts; every call tears down
// the timer/channel so repeated calls are always safe.
if (attachedFlag && !detachedFlag) {
detachedFlag = true;
channel.postMessage({ type: 'detach', participantId: participantId });
}
clearTimeout(timer);
channel.close();
}
return {
channel: channel,
isAttached: function () { return attachedFlag; },
/** Route a native surface interaction through the owner's canonical
* mutation path. No-op (returns false) if not yet attached. */
mutate: function (kind, target, valueOrArgs) {
if (!attachedFlag) 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: detach
};
}
var XZBTSurfaceBus = {
createOwner: createOwner,
attach: attach
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = XZBTSurfaceBus;
}
if (root) {
root.XZBTSurfaceBus = XZBTSurfaceBus;
}
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));
@@ -0,0 +1,108 @@
/**
* XZBT Surface Mode — SciFi-XZBT (Step 6.7B)
*
* Pure, testable seam for resolving which boot mode this document load is in
* (Contract 5.3 §31.4 query-string surface form) and for describing the
* exhibit's two-entry surface catalog. No DOM access, no imports, no
* side effects — safe to load and exercise under Node's `vm` module.
*
* Two surfaces only:
* - surface.console the full operator console (index.html, primary)
* - surface.observation the same document, rendering only the
* Observation viewscreen, addressed by
* ?surface=observation&xi=<instanceId>
*/
(function (root) {
'use strict';
var CHANNEL_PREFIX = 'xzbt-scifi-surface-v1:';
/**
* Resolve the boot mode + instance id for a location-like object.
* @param {object} locationLike anything with a `.search` (or `.href`)
* string, e.g. `window.location`.
* @returns {{mode: 'console'|'observation', instanceId: string|null}}
*/
function resolve(locationLike) {
var search = '';
if (locationLike && typeof locationLike.search === 'string') {
search = locationLike.search;
} else if (locationLike && typeof locationLike.href === 'string') {
var qIdx = locationLike.href.indexOf('?');
search = qIdx >= 0 ? locationLike.href.slice(qIdx) : '';
}
var params;
try {
params = new URLSearchParams(search);
} catch (err) {
params = null;
}
var surfaceParam = params ? params.get('surface') : null;
var instanceId = params ? params.get('xi') : null;
if (surfaceParam === 'observation') {
return { mode: 'observation', instanceId: instanceId || null };
}
// Unknown/absent `?surface=` values resolve to console. `xi` is
// meaningless in console mode.
return { mode: 'console', instanceId: null };
}
/** Mint a fresh per-document-load instance id. */
function newInstanceId() {
return 'xi-' + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
/**
* The exhibit's two-entry surface catalog (Contract 5.3 §31.2/§31.3).
* @param {string} instanceId the live console instance id.
*/
function SURFACES(instanceId) {
return [
{
id: 'surface.console',
label: 'Main Console',
kind: 'surface',
primary: true,
url: 'index.html',
role: 'control',
category: 'console',
description: 'The full SciFi-XZBT operator console.'
},
{
id: 'surface.observation',
label: 'Observation',
kind: 'surface',
primary: false,
url: '?surface=observation&xi=' + encodeURIComponent(instanceId),
role: 'ambient',
category: 'observation',
aspectRatio: '16:9',
requires: ['observation'],
description: 'Full-screen procedural viewscreen mirroring the console.'
}
];
}
/** The instance-scoped BroadcastChannel name for a console instance id. */
function channelName(instanceId) {
return CHANNEL_PREFIX + instanceId;
}
var XZBTSurfaceMode = {
resolve: resolve,
newInstanceId: newInstanceId,
SURFACES: SURFACES,
channelName: channelName
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = XZBTSurfaceMode;
}
if (root) {
root.XZBTSurfaceMode = XZBTSurfaceMode;
}
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));
@@ -1,5 +1,5 @@
/*
* XZBT Exhibit Contract 5.2 — generic contract core.
* XZBT Exhibit Contract 5.3 — generic contract core.
*
* WHY THIS FILE IS GENERIC
* ------------------------
@@ -26,25 +26,33 @@
'use strict';
var CONTRACT_MAJOR = 5;
var CONTRACT_MINOR = 2;
var XZBT_VERSION = '5.2';
var CONTRACT_MINOR = 3;
var XZBT_VERSION = '5.3';
/*
* Contract 5.3 presentation-surface support (additive, optional).
* Contract 5.3 presentation-surface support.
*
* 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.
* `surfaces` remains OPTIONAL per-exhibit: an exhibit that does not supply
* a SurfaceCatalog simply gets no `surfaces` key in `describe()` at all
* (Contract 5.3 §7). This file remains domain-free; it knows the *shape*
* of Contract 5.3 Section 31, not any exhibit's surface content.
*
* `CONTRACT_MINOR`/`XZBT_VERSION` above are this module's defaults, used by
* any exhibit that does not pass its own `contractMinor`/`xzbtVersion` to
* ContractCore. XZBT-NGN's maintained exhibit set moves forward with the
* contract (Contract 5.3 Changelog, §39): there is no standing requirement
* to keep a *currently maintained* exhibit frozen on an old minor version
* merely because it once shipped against it. An exhibit that has a real,
* independently justified reason to stay on an older minor may still pass
* an explicit lower `contractMinor`/`xzbtVersion` — nothing here prevents
* that — but it is no longer the silent default.
*/
/* Contract 5.2 §15. The exhibit assigns source at its own trusted
/* Contract §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'];
/* Contract 5.2 §24. */
/* Contract §24. */
var ERROR_CODES = [
'UNSUPPORTED_VERSION',
'INVALID_MESSAGE',
@@ -59,10 +67,10 @@
'INTERNAL_ERROR'
];
/* Contract 5.2 §17. */
/* Contract §17. */
var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
/* Contract 5.2 §16. The base event set is closed; exhibits do not invent
/* Contract §16. The base event set is closed; exhibits do not invent
* new canonical event types. */
var EVENT_TYPES = [
'state.changed',
@@ -746,14 +754,13 @@
if (!isPlainObject(message)) {
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
}
if (message.xzbt !== this.xzbtVersion) {
return this._errorEnvelope(
message.requestId || null,
message.sessionId || null,
'UNSUPPORTED_VERSION',
'This exhibit implements contract ' + this.xzbtVersion + '.'
);
}
/* `xzbt` is advisory metadata, not a compatibility gate (Contract §6.5):
* a 5.3-aware host must still interoperate with a 5.2 exhibit and vice
* versa. Real compatibility is negotiated by handleHello below via
* `supportedContractMajors`/`contract.major`/`minor`; rejecting here on
* an exact string mismatch pre-empted that negotiation and made it
* impossible for a host and exhibit that both implement the contract,
* but stamp different advisory xzbt strings, to ever connect. */
if (typeof message.type !== 'string') {
return this._errorEnvelope(
message.requestId || null,
@@ -1,5 +1,5 @@
/*
* XZBT Exhibit Contract 5.2 — same-origin postMessage host transport.
* XZBT Exhibit Contract 5.3 — same-origin postMessage host transport.
*
* This is layer 6 of the Authoring Guide's recommended separation, and it is
* deliberately the thinnest file in the project. It knows how to move
@@ -61,7 +61,15 @@
var message = event.data;
if (!message || typeof message !== 'object') return;
if (message.xzbt !== window.XZBTContractCore.VERSION) return;
/* `xzbt` is advisory metadata, not a version gate (Contract §6.5): real
* compatibility is negotiated by ContractCore.handleRequest/handleHello
* via contract major/minor, not by string-matching the caller's
* advisory tag against this exhibit's own static module version here.
* (This used to compare against window.XZBTContractCore.VERSION, the
* shared module's fixed default -- never the per-instance version an
* exhibit actually negotiates -- so it silently discarded every
* message from any host whose advisory tag didn't happen to equal
* that hardcoded default, including well-formed hellos.) */
/* Source is assigned here, at the trusted receiving boundary. */
var response = this.core.handleRequest(message, 'host');