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
+14 -1
View File
@@ -55,10 +55,23 @@ Authoring Guide, and owns the NGN Implementation Plan. Documents in
Reference exhibits under `test-fixtures/` are test/reference fixtures, not product
code. Keep exhibit-specific names and paths in test configuration or documentation.
- XZBT Exhibit Contract Specification v5.2
XZBT-NGN and its maintained reference exhibits move forward with the contract.
Contract 5.3 is the current maintained contract; it supersedes 5.2 additively
(no 5.2 normative requirement was removed, weakened, or reinterpreted). The
`xzbt` envelope field is advisory metadata only (Contract §6.5) and MUST NOT be
used as an exact-string compatibility gate anywhere in this repository --
real compatibility is negotiated by the handshake and contract fields.
Backward compatibility with a genuine 5.2 exhibit remains a protocol-level
requirement of the contract itself (Section 28.2), but that is a different
thing from keeping a *currently maintained* reference exhibit or its test
coverage frozen on 5.2: there is no such requirement, and maintained exhibits
are expected to advance with the contract instead.
- XZBT Exhibit Contract Specification v5.3 (current; supersedes v5.2 below)
- XZBT-NGN Exhibit Engine Implementation Plan v5.2
- XZBT Exhibit Authoring Guide v5.2
- Step 3.7 reference exhibit report
- XZBT Exhibit Contract Specification v5.2 (historical predecessor, retained for context)
- Do not infer XZBT-NGN behavior from SciFi-XZBT source code.
- SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
+42 -10
View File
@@ -1,6 +1,7 @@
# XZBT-NGN Exhibit Engine
A single-exhibit host for XZBT Exhibit Contract 5.2. It negotiates sessions,
A single-exhibit host for XZBT Exhibit Contract 5.3 (current; see
[Canonical documentation](#canonical-documentation)). It negotiates sessions,
discovers descriptor-driven controls, inspects reported state, sends set/invoke
operations, and displays capabilities, events, revisions, and protocol errors.
@@ -9,7 +10,9 @@ Step 5 implementation and verification are recorded in
descriptors are corrected and both text invocations pass through the operator UI.
Step 5 is complete: 27 automated tests and all three browser reference checks
pass. Haunted House reports conformant values, and publishing validation passes.
Step 6 is planned but has not begun.
Steps 6.16.3 established Contract 5.3 surface discovery and interoperability.
Step 6.4 adds local presentation panes; see the
[Step 6.4 report](docs/architecture/XZBT-NGN-Step6.4-Local-Surfaces.md).
## Run locally
@@ -28,6 +31,12 @@ the discovered controls. Enter another path and select **Load exhibit** to switc
exhibit instance starts with its own initial state.
- **Disconnect** removes the frame and clears session, catalog, state and events.
- **Refresh state** reads a new authoritative snapshot.
- **Presentation surfaces** provides Open, Reload and Close for each local view,
with its label, primary status and lifecycle state. URLs resolve against the
supplying exhibit's document URL, and must remain same-origin. NGN closes its
surface panes when disconnecting, reconnecting the session or switching exhibits.
A primary surface already represented by the control frame is marked as open;
its lifecycle remains under the Connection controls.
- Controls are grouped by the exhibit's category (or Other when omitted).
Reported values and draft inputs are distinct; select **Set** or **Invoke**
to submit. Rediscovery resets drafts.
@@ -41,6 +50,8 @@ Included reference fixture paths:
/test-fixtures/reference-exhibits/aquarium/index.html
/test-fixtures/reference-exhibits/planetarium/index.html
/test-fixtures/reference-exhibits/haunted-house/index.html
/test-fixtures/reference-exhibits/museum-gallery/control.html
/test-fixtures/reference-exhibits/scifi/index.html
```
These are locally corrected test/reference fixtures, not NGN product code or
@@ -69,34 +80,55 @@ needed for ordinary operation.
## Canonical documentation
This repository is the canonical home for the shared
[XZBT Exhibit Contract Specification 5.2](docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md)
and [XZBT Exhibit Authoring Guide 5.2](docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md).
[XZBT Exhibit Contract Specification 5.3](docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md) --
the current maintained contract, which additively supersedes
[Contract Specification 5.2](docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md)
(retained as a historical predecessor; no 5.2 normative requirement was
removed, weakened, or reinterpreted) -- and for the
[XZBT Exhibit Authoring Guide 5.2](docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md).
The [NGN Implementation Plan 5.2](docs/architecture/XZBT-NGN-Exhibit-Engine-Implementation-Plan-v5.2.md)
also belongs here. Its historical phase numbering describes the longer roadmap;
the current Step 5 completion pass does not authorize those later features.
SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
`docs/reference/` contains external implementation context, not host requirements.
NGN and its maintained reference exhibits (Museum Gallery, SciFi-XZBT, Haunted
House, Aquarium, Planetarium) move forward with the contract: there is no
requirement to keep a currently maintained exhibit, or its test coverage,
frozen on an old minor version. The `xzbt` envelope field is advisory metadata
only (Contract §6.5) and is never used as an exact-string compatibility gate;
real compatibility is negotiated by the handshake and contract fields. A
genuine, unmaintained Contract 5.2 exhibit remains protocol-compatible with
this host (Contract §28.2) -- that guarantee is unrelated to which minor
version the exhibits maintained in *this* repository currently declare.
## Implementation and MVP boundary
- `src/host.js`: negotiation, requests, discovery, state cache, events and recovery.
- `src/connection.js`: URL validation and frame load/disconnect/reconnect lifecycle.
- `src/surface-url.js`, `src/local-surfaces.js`: canonical surface URL resolution
and local presentation frame lifecycle, without additional contract sessions.
- `src/validation.js`: descriptor, state value and argument validation.
- `src/transport/post-message.js`: same-origin frame transport.
- `src/ui.js`, `public/`: generic operator interface.
- `server/serve.js`: loopback static server.
- `tests/`, `test-fixtures/`: automated checks, reference fixtures and browser evidence.
The MVP supports one exhibit, Contract 5.2 discovery, generic target rendering,
The MVP supports one exhibit, Contract 5.3 discovery, generic target rendering,
state inspection, set/invoke, capabilities, events, session/revision/sequence
tracking, reconnect and synchronization recovery. It has in-memory state only;
logs retain 200 entries. Idle polling is not enabled. The last connection URL is
remembered only until the page is reloaded.
remembered only until the page is reloaded. Contract 5.3 presentation-surface
discovery and local rendering (Section 31) are implemented and exercised by the
Museum Gallery reference exhibit; other maintained exhibits do not declare
multiple surfaces. Surface errors stay within the affected pane. Remote/cast
display assignment remains deferred. Shared-state synchronization proof remains
Step 6.5; this step adds no host-side surface communication.
Not implemented: multi-surface presentation, casting/display endpoints, MIDI,
MCP/webhooks, scenarios/recording, telemetry acquisition, multi-exhibit
orchestration, persisted operator configurations, remote deployment, accounts,
databases, cloud services, plugins, commercial licensing or production branding.
Not implemented: casting/display endpoints, MIDI, MCP/webhooks,
scenarios/recording, telemetry acquisition, multi-exhibit orchestration,
persisted operator configurations, remote deployment, accounts, databases,
cloud services, plugins, commercial licensing or production branding.
## Publishing records
@@ -0,0 +1,165 @@
# Step 6.4 — Local Multi-Surface Rendering
Verified 2026-09-14. **Step 6.4 complete.** Contract 5.3 presentation surfaces
can be rendered locally through the normal NGN operator UI.
## Pre-change architecture
`ExhibitConnection` owned one iframe, remembered its normalized same-origin
URL in `connection.url`, and passed it to `ExhibitHost.connect()`.
`ExhibitHost` stored that URL as `exhibitBaseUrl`; discovery called
`validateSurfaceCatalog()` and stored the surviving descriptors in `host.surfaces`.
The UI displayed descriptive surface cards only. The primary iframe alone used
`postMessageTransport`, which checked both source window and origin.
There was no separate window/frame lifecycle helper for presentation views.
Museum Gallery's `control.html` is both its entry point and declared primary.
Only this page constructs its Contract Core. `artifact.html` and `info-wall.html`
attach through the fixture's existing BroadcastChannel bus and construct no
independent Core. NGN does not participate in that bus. SciFi is an external
reference implementation, not a source of NGN architecture.
The working tree already contained Step 6.3 contract/fixture/test/documentation
edits, including an untracked postMessage interoperability test. They were
preserved; this task did not commit, push or modify publishing records.
## Files changed for this step
| File | Change |
| --- | --- |
| `src/surface-url.js` | Canonical standards-based resolver |
| `src/validation.js` | Reuse resolver; validate optional metadata types |
| `src/local-surfaces.js` | Local frame ownership, resource checks and lifecycle |
| `src/connection.js` | Establish base from final same-origin control document after navigation |
| `src/ui.js` | Descriptor-driven lifecycle controls, state and pane rendering |
| `public/index.html` | Operator explanation of local surfaces |
| `tests/local-surfaces.test.js` | 17 URL, lifecycle, failure, trust and resource tests |
| `tests/connection.test.js` | Final-document URL/navigation regression test |
| `README.md` | Current functionality and deferred scope |
| This report | Architecture, behavior and verification |
| `test-fixtures/evidence/step6.4-*.txt` | Live Museum and SciFi DOM snapshots |
No framework, dependencies, exhibit-specific host branches or schema fields
were added. Existing CSS supplies pane sizing and button styles.
## Resolution and trust
`resolveSurfaceURL(value, exhibitBaseUrl)` uses `new URL()` and returns a
normalized absolute URL. Both discovery and opening use it. Sibling paths,
`./`, parent paths, query-only and fragment-only references resolve against the
supplying exhibit document, never NGN's administrative page. Absolute URLs
(including same-origin ones), protocol-relative URLs, invalid values, control
characters, surrounding whitespace and cross-origin results are rejected.
HTTP(S) and credential-free results are required. The existing static server
continues to enforce mount and filesystem containment for both control and
presentation resources.
Individual invalid entries produce discovery diagnostics without invalidating
targets/state. The exactly-one-primary rule is applied to the surviving set.
Malformed optional metadata cannot reach the UI's string/array operations.
Each open/reload first performs a same-origin HEAD resource check with
`redirect: 'error'`. Missing pages become surface errors; redirects are refused
before the iframe is created. Frame loads additionally check the resulting
origin. Descriptor text is inserted as text, never evaluated. No presentation
frame receives a host transport or session, and the existing postMessage
source/origin checks are unchanged. A test demonstrates that a same-origin
presentation window cannot impersonate the control peer through this transport.
## Rendering, primary handling and lifecycle
NGN uses inline iframe panes, one per opened descriptor ID. Nothing is
automatically opened beyond the existing control frame. A primary descriptor
whose complete normalized URL matches the control document is shown as already
open there, with separate surface actions disabled. Its lifecycle belongs to
the Connection panel. A separate primary URL is offered through the same Open
action as any other presentation view. Queries/fragments remain significant.
States are `closed`, `loading`, `open`, `error`, and `control` (already rendered
by the authoritative frame). Open during loading/open is a no-op. Reload
replaces only the selected presentation document. Close removes it and clears
its diagnostic; Open recreates it. UI cards show labels, primary/non-primary
status, capability availability, lifecycle state and failures.
Disconnect, session reconnect, exhibit replacement and host page departure
release owned frames and abort pending checks. Late load/check completions
cannot recreate abandoned panes. Removed/changed descriptors release their
resources on rediscovery; unchanged frames survive metadata updates. An observer
detects externally removed pane frames and updates their state to closed.
HTTP failures, frame errors, origin failures and a 15-second load timeout stay
local to the affected surface. Reload can retry them. The authoritative control
session and synchronized host state remain intact.
## Automated verification
Final focused command:
```text
node --test tests/local-surfaces.test.js tests/surface-validation.test.js tests/postmessage-interop.test.js tests/connection.test.js
80 passed, 0 failed
```
Coverage includes every requested relative URL form and rejection category;
real Museum descriptors loaded from its actual adapter; duplicate-open, reload,
close, reopen, disconnect/switch cleanup, pending-load cancellation, registry
refresh, distinct primary pages, malformed metadata, timeouts, external removal,
cross-origin loads, source impersonation and real HTTP success/404/redirect cases.
Lifecycle tests use the production manager and ExhibitHost with EventTarget
frames; the existing Step 6.3 suite retains the real production host and exhibit
postMessage bridges in browser-like window realms.
Full `npm test`: **118 passed, 0 failed**, including Museum Gallery and SciFi
connection/synchronization regressions and existing Step 6.3 tests.
Node test workers required execution outside the Windows sandbox after its
initial `spawn EPERM` denial. `python devlog_editor.py --validate` passed.
`git diff --check` passed.
## Live browser verification
Performed in the Codex in-app browser against `npm start` at
`http://127.0.0.1:4173/`, using the ordinary operator UI:
1. Loaded the requested full Museum `control.html` URL: connected,
synchronized, Contract 5.3, three surface descriptors.
2. Primary card reported the existing control frame and disabled duplicate Open.
3. Opened Artifact Display and Information Wall: both reported `open`, rendered
their respective page headings/content and displayed “Attached to Control Room.”
4. Reloaded Artifact Display; closed Information Wall (confirmed `closed`),
then reopened it (confirmed `open`).
5. Switched to SciFi: both Museum cards/panes disappeared; SciFi reported
connected, synchronized, Contract 5.3.
6. Reloaded Museum: both secondary cards began closed and opened cleanly.
7. Explicit Disconnect cleared all frames/catalog; Reconnect rediscovered the
closed surfaces and Artifact Display reopened successfully.
8. Loaded SciFi again after the final UI changes: connected and synchronized;
no Museum surface cards remained.
Saved observations:
- [Museum rendered panes](../../test-fixtures/evidence/step6.4-museum-local.txt)
- [SciFi regression](../../test-fixtures/evidence/step6.4-scifi-regression.txt)
An early batched browser Close action did not change the page; after inspecting
the unchanged state, individual Close/Reopen actions passed, including a repeat
on the final UI. Failure injection is automated coverage, not a claim of live
missing-page or timeout testing.
## Limitations and deferred work
- `open` means the document loaded, not that its application scripts are healthy
or that cross-surface state synchronization has been proven.
- HEAD support is required. HTTP redirects, including same-origin redirects,
are conservatively refused for presentation resources. The local static server
supports HEAD and does not issue redirects.
- These are trusted same-origin exhibit pages under the existing trust model,
not a sandbox for hostile exhibit code. A page's later script-driven navigation
can only be detected at load; resource checks do not certify application code.
- Museum's existing initial shared-state attachment was observed. Its Control
Room counter/footer text did not consistently reflect that attachment. No
fixture behavior was changed; mutation propagation proof remains **Step 6.5**.
- No remote/cast display assignment, device discovery, networking clients,
fullscreen orchestration, SciFi Observation adaptation or synchronization
architecture was introduced.
All Step 6.4 acceptance categories pass. Step 6.5 has not been implemented.
@@ -0,0 +1,945 @@
# Step 6.7A — SciFi Observation Surface Architecture
**Status:** design only. No implementation performed. No source files modified.
**Scope:** adapting SciFi-XZBT Observation to the already-proven Contract 5.3
local presentation-surface model (Steps 6.36.6).
**Audience:** the implementation model executing Step 6.7B.
### Repositories inspected
| Repo | Path | Role |
| --- | --- | --- |
| XZBT-NGN | `G:/.vibe/XZBT-NGN` | Host engine, contract spec, test fixtures |
| SciFi-XZBT | `G:/.vibe/SciFi-XZBT` | **The real exhibit source.** Step 6.7B edits here. |
`test-fixtures/reference-exhibits/scifi/` inside XZBT-NGN is a *copy* of
SciFi-XZBT, kept for NGN's own tests (`test-fixtures/PROVENANCE.md`). Step 6.7B
must change SciFi-XZBT and then resync the fixture. Both working trees carry
uncommitted work; nothing was modified by this task.
---
## 1. Recommendation
**Adopt Candidate A in its narrowest form: one document, one script set, two boot
modes.** SciFi advertises exactly two surfaces. The primary is the existing
`index.html` (unchanged, the full console). The Observation surface is
*the same document* re-served with a query string — `?surface=observation&xi=<instanceId>`
— which Contract 5.3 §31.4 explicitly provides for ("a bare query string and/or
fragment, resolving against the exhibit's own base document, for a single-page
exhibit whose surfaces are views within one already-served document"). A boot-mode
resolver read at the top of `app.js` decides which mode the instance runs in. In
Observation mode the instance constructs no audio graph, no generative engine, no
contract host bridge, and no ambient event scheduler; it renders the existing
`#observation-overlay` and mirrors state from the authoritative console instance
over an exhibit-internal, instance-scoped `BroadcastChannel`.
This is the only candidate that survives contact with the actual code. Roughly
3,230 of `app.js`'s 4,703 lines (lines ~12614494) are the Observation renderer,
and they live inside `app.js`'s single IIFE closure, reading `activeUniverseId`,
`activePresetId`, `isPlaying`, `observationActivity`, `UniverseRegistry`,
`observationEngine`, `alerts` and `audioManager` as free variables.
`observation-engine.js` additionally reads five *console-panel* element IDs
(`toggle-observation-viewport`, `val-observation-viewport`,
`toggle-observation-pillars`, `val-observation-pillars`, and the preset select)
that do not exist outside the console document. Keeping one document means **zero
renderer extraction and zero DOM decoupling**: every element the renderer reaches
for is still present, merely hidden by a body class. A dedicated lightweight page
(Candidate B) would require lifting that entire closure into a shared module —
the single largest and riskiest refactor available in this project — and would
still not fit the distribution, because `tools/package.ps1` inlines exactly one
input document and emits exactly one output file. A second HTML document simply
cannot exist in the standalone single-file build.
The second load-bearing decision: **surface existence is not `view.observation`.**
`view.observation` keeps its current meaning exactly — the console page's own
full-screen overlay — and opening or closing the NGN Observation pane never
touches it. Conflating them would black out the operator's console the moment
they opened the presentation pane, which is precisely the simultaneity failure
Candidate C is rejected for. The Observation surface renders the scene
unconditionally while it is open; that is its whole purpose and it needs no state
flag to say so.
---
## 2. Current SciFi state architecture
### 2.1 Where state actually lives
`app.js` is one `DOMContentLoaded` IIFE. There is no state object. Authority is
distributed across subsystem instances and three closure variables, and
`getContractStateSnapshot()` (app.js:4666) is the one function that composes them
into the contract-visible view.
| Contract state | Authoritative owner | Read path |
| --- | --- | --- |
| `universe.selected` | `let activeUniverseId` (app.js:29) | closure var |
| `preset.selected` | `let activePresetId` (app.js:30) | closure var |
| `transport.playing` | `let isPlaying` (app.js:28) | closure var |
| `transport.muted`, `mix.master` | `AudioManager` | `audioManager.isMuted` / `.getMasterVolume()` |
| `mix.hull.*` | `HullDroneSynth.params` | `hullDrone.params.*` |
| `mix.drive.*` | `WarpCoreSynth.params` | `warpCore.params.*` |
| `mix.environment.*` | `LifeSupportSynth.params` | `lifeSupport.params.*` |
| `mix.telemetry.*` | `TelemetrySynth.params` | `telemetry.params.*` |
| `fnc.enabled`, `fnc.level` | `FuturisticNoiceCancellation` | `fncSystem.enabled` / `.level` |
| `speech.robot-amount` | `XZBTGenerativeExperience` | `generativeExperience.roboticAmount()` |
| `alert.active` | `AlertSynth.activeAlert` | `alerts.activeAlert` |
| `view.observation` | `let observationActive` (app.js:1281) | closure var |
| `view.activity` | `let observationActivity` (app.js:1282) | closure var |
| `view.viewport-frame` | `ObservationEngine.showViewport` | `observationEngine.showViewport` |
| **`showPillars`** *(no target)* | `ObservationEngine.showPillars` | — |
| **warp flight** *(no target)* | `ObservationEngine` internal | — |
| **HUD hold** *(no target)* | console checkbox DOM only | — |
**Canonical vs reflected.** The subsystem/closure values above are canonical. All
DOM is *reflected*: every setter in the adapter's `bindings.setters` writes the
subsystem first and then assigns `slider.value` / `label.textContent`. There is
exactly one genuine exception — **HUD hold** is stored nowhere but
`#toggle-observation-hud.checked`, which is why it is treated as surface-local in
§7 rather than promoted to a target.
**Observation does not own its own mutation path.** `observationEngine.onMutation`
(app.js:259) routes every dock interaction straight into
`contractAdapter.applyMutation(...)` with source `'ui'` — except `togglePillars()`
and `toggleWarpFlight()`, which mutate engine-local fields directly because no
canonical target exists for them (see §11).
**The adapter reads the same authority.** `bindings.getState` is literally
`() => getContractStateSnapshot()`. There is no second state computation anywhere.
### 2.2 Flow diagram (actual)
```text
NGN (postMessage) console UI hotkeys Observation dock
| | | |
| | | | observationEngine.onMutation
v v v v
adapter.handleMessage +------------+---------------+
| |
v v
XZBTControlBus (intercepted) --> contractAdapter.applyMutation / invokeAction
| [ THE one chokepoint ]
v
bindings.setters[id] / invokers[id]
|
+-------------------+-----------+-----------+-------------------+
v v v v
audio subsystems closure vars ObservationEngine reflected DOM
(AudioManager, (activeUniverseId, (showViewport, canvas (sliders, labels,
synths, FNC, activePresetId, scene, bezels) dock button text)
alerts) isPlaying,
observationActive,
observationActivity)
| | |
+---------+---------+-----------------------+
v
_commitChanges() -> getContractStateSnapshot() diff
|
+--> stateRevision++ , state.changed / selection.changed
+--> _emitEvent(...) [ GATED on sessionActive ]
|
v
NGN host session
```
**The gate marked above is critical for 6.7B.** `_emitEvent` begins
`if (!this.sessionActive) return;` (contract-adapter.js:596). With no NGN attached,
**no events are emitted at all.** A mirror built on contract events would therefore
go dead in standalone multi-window use. §6.4 specifies the fix.
### 2.3 Observation entry/exit today
- `enterObservation()` (app.js:4443) — calls
`generativeExperience.prepareExperience()` **(the WebLLM/Kokoro loader)**, sets
`observationActive = true`, adds `.active` to `#observation-overlay`, calls
`refreshObservation()`, starts the HUD/return fade timers, and calls
`scheduleObservationAmbientActivity()`.
- `exitObservation()` (app.js:4460) — stops the engine, the ambient timer, live
instrumentation, spline morphs and the scene loop, then clears the stage markup.
- `refreshObservation()` (app.js:4422) — injects `getObservationSvg(activeUniverseId)`,
starts the scene and instrumentation, and calls `observationEngine.start(activeUniverseId)`.
- Every entry/exit path (button app.js:4502, background click 4508, return pill 4517,
Escape 4574) goes through `applyMutation('view.observation', …)`. Nothing toggles
`observationActive` directly. This is already clean.
### 2.4 Assets, packaging and existing surface concepts
- `index.html` loads one stylesheet and ten scripts in a **fixed order**, all
classic globals. `agents.md` forbids ES modules, dependencies and network access
outside the one bounded `generative-experience.js` exception.
- `tools/package.ps1` walks `index.html` line by line and inlines any
`<script src="…"></script>` or `<link rel="stylesheet" href="…">` that matches
its exact regex, emitting **one** file, `dist/SciFiAmbientDisplay_V<commit>.html`.
One input document, one output document. There is no other build step.
- **No existing surface concept.** `describe()` (contract-adapter.js:854) returns
`exhibit / contract / registryRevision / stateRevision / capabilities / targets`
and no `surfaces` field. SciFi is currently a conformant Contract 5.3 exhibit in
§31.3 form 1 ("absent").
- The nearest adjacent concept is the **TV PRESENTATION** card (16:9 aspect lock,
render density). It is console-local presentation tuning, not a surface, and
Step 6.7 should not disturb it.
- **No Step 6.7 planning notes exist** in either repo. The only forward references
are `STEP5-MVP-COMPLETION-REPORT.md:234` (the step list) and
`docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md:393`, which names §7.2/§8
as "the reusable template Phase 6.7 adapts" — that template is the attachment
sequence this design follows.
---
## 3. Candidate comparison
| | **A — same document, surface mode** | B — dedicated lightweight page | C — reuse control page/overlay |
| --- | --- | --- | --- |
| Architectural cleanliness | Good. One app, one code path, mode is a boot input. | Cleanest *in theory*; unreachable in practice. | Poor. Conflates host UI with exhibit presentation. |
| Renderer refactoring | **None.** All ~3,230 renderer lines and all DOM stay put. | Extract the renderer from `app.js`'s closure; decouple 5 console-panel element reads in `observation-engine.js`. Very large. | None. |
| Standalone behavior | Untouched. `index.html` with no query is byte-for-byte today's app. | Untouched, but the new page has no place in the single-file build. | Untouched. |
| Duplicate initialization | Controlled: mode guard skips audio, AI, adapter bridge, visualizer, hotkeys, timers. | Structurally impossible to duplicate. | N/A. |
| State authority | Single. Presentation instance never calls `applyMutation` locally. | Single. | Single, but only one view can exist. |
| Sync complexity | One instance-scoped BroadcastChannel; Museum's proven shape. | Same. | None. |
| Contract purity | Exactly the §31.4 query-string form the spec provides for. | Fine. | Fine, but defeats §31.1's "independently viewable". |
| NGN genericity | No NGN change. | No NGN change. | Would tempt NGN into exhibit-specific pane handling. |
| Future cast suitability | Good — the surface is already a standalone URL with its own viewport. | Best (smallest payload), marginally. | Bad. |
| Packaging | `package.ps1` unchanged; two new inlined scripts. | **Breaks.** Second document cannot be packaged. | Unchanged. |
| Implementation risk | Moderate, concentrated in `app.js` mode branching. | High. | Low, but wrong. |
### Rejections
- **B — Dedicated lightweight Observation surface.** Rejected on two independent
grounds, either sufficient. (i) *Packaging:* `tools/package.ps1` produces a single
self-contained document; a second HTML page has nowhere to go in the offline
deliverable, so SciFi would fork into two maintained applications — explicitly
forbidden by the brief. (ii) *Cost and risk:* it requires lifting ~3,230 lines out
of a shared closure that `agents.md` marks "surgical, scoped edits — do not
rewrite", plus decoupling `observation-engine.js` from five console-panel element
IDs. The payoff is a lighter payload; but under Candidate A the presentation
instance already parses the same bytes the browser has cached and *runs* almost
none of the heavy subsystems, so the real saving is parse time, not runtime. Not
worth the refactor. Revisit only if remote casting later demands a minimal payload.
- **C — Reuse the existing page / overlay.** Rejected: it makes simultaneous
control and observation impossible, which is the entire point of the multi-surface
model (§31.1 "independently viewable"); NGN would end up manipulating exhibit DOM,
breaking the generic boundary Step 6.4 established; and it has no path to casting.
- **D — Better architecture found in the repository.** None found. The repository
does, however, supply two refinements adopted into A: the §31.4 query-string
surface form, and Museum Gallery's attach/snapshot/mutate/detach bus — improved
here with an instance-scoped channel name (§6.2).
---
## 4. Surface catalog
Two surfaces. No more are justified: the console is one coherent operator view,
and Observation is the one thing an audience is meant to look at. Adding
per-universe or per-panel surfaces now would be speculative.
```js
// js/surface-mode.js — instanceId is the live per-document id (see §6.2)
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.'
}
];
}
```
Schema notes, all verified against §31.2 and `src/validation.js`:
- `id` uses the canonical target-ID grammar (§8.1) and matches Museum's
`surface.control` / `surface.info-wall` convention.
- `kind: 'surface'` is the required constant. Exactly one `primary: true`.
- Only documented optional fields are used: `description`, `role`, `aspectRatio`,
`category`, `requires`. **No invented fields.** No field names a transport,
endpoint or device class (§31.9).
- `requires: ['observation']` reuses the adapter's already-declared `observation`
capability (contract-adapter.js:52).
### Why `url: 'index.html'` for the primary
`src/local-surfaces.js:35` marks a descriptor as the already-open control pane
with **full URL equality** against `host.exhibitBaseUrl`, and
`src/connection.js` sets that base from `frame.contentWindow.location.href` after
load. Connecting NGN to `…/scifi/index.html` — the URL already used by
`README.md:54` and the Step 6.6 procedure — makes `index.html` resolve to exactly
the base, so the primary reuses the existing session frame and is never opened a
second time. See risk **R1** for the trailing-slash case.
### Why the Observation `url` is a bare query
§31.4 sanctions it for single-page exhibits, `resolveSurfaceURL` accepts it
(relative, same-origin, no scheme), and `server/serve.js` routes on `url.pathname`
only, so the `HEAD` resource check in `checkSurfaceResource` returns 200. No new
file is served and `package.ps1` never sees a second document.
---
## 5. Authority and synchronization
### 5.1 Ownership rule
> The instance whose resolved mode is `console` is the sole authority. It owns
> every subsystem, the one contract session, and the one mutation chokepoint. An
> instance whose mode is `observation` owns **nothing**: it holds a read-only
> mirror and may only *request* mutations.
The presentation instance never calls `contractAdapter.applyMutation`,
`invokeAction`, or any `bindings.setter`. It never constructs a contract session:
it does not install the window message bridge, so a stray `hello` cannot reach it,
and NGN never offers one — `LocalSurfaces` attaches no transport to surface frames
(`src/local-surfaces.js`, and `tests/local-surfaces.test.js:232` asserts a
presentation window cannot impersonate an authoritative peer).
### 5.2 Mechanism: instance-scoped BroadcastChannel
Chosen mechanism: `BroadcastChannel`, named
`xzbt-scifi-surface-v1:<instanceId>`, with the console document as owner —
Museum Gallery's proven shape (`surface-bus.js`), with one deliberate improvement.
Evaluated and rejected:
- **Explicit parent/child `postMessage`.** *Structurally impossible here.* Under
NGN the console document and the Observation iframe are **siblings** inside the
NGN page, not parent and child. Neither holds a window handle to the other.
This alone decides the mechanism.
- **`storage` events / same-origin storage.** No request/response, so late join
needs a polled or mirrored snapshot key; fires only in *other* documents;
serializes on every change. Strictly worse.
- **`SharedWorker`.** Extra lifecycle and an unsupported-context risk, for no
benefit over BroadcastChannel.
- **Extending `XZBTControlBus`.** It is an in-document registry with no transport;
extending it means writing one of the above anyway. Keep it as-is.
**The improvement over Museum: the channel name carries an instance id.** Museum
uses one fixed channel name, which means two independent Museum tabs on one origin
would cross-talk. For SciFi that matters more: two console tabs is a normal thing
to do. The console mints `instanceId` once per document load, publishes it inside
the Observation surface URL (which `describe()` produces at call time, so it is
always current), and the presentation instance reads it from its own query string.
A surface with an unknown or stale id finds no owner and lands in the waiting
state rather than attaching to the wrong console.
### 5.3 Message shapes (exhibit-internal; never seen by a host)
```
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 }
```
This carries no XZBT envelope, no `xzbt` field, no `sessionId`, and is never
observed by NGN — §31.7's explicit allowance. Participant bookkeeping is a
`Set` of `participantId`s, not a counter, so duplicate attach or late detach
cannot ratchet the count (Museum's lesson; see its participant-lifecycle tests).
### 5.4 The event-gate correction (mandatory)
Because `_emitEvent` early-returns unless `sessionActive`, the mirror **must not**
be fed from contract events. Add one exhibit-internal hook to the adapter,
independent of session state:
```js
// contract-adapter.js, inside _commitChanges(), after the event loop:
if (this.onLocalChange) {
for (const target of changes) {
this.onLocalChange(target.id, after[target.id], this.stateRevision);
}
}
// and in _invokeAction(), after _commitChanges:
if (this.onLocalAction) this.onLocalAction(canonicalId, sanitizedArgs);
```
Contract event semantics are untouched — a host still only sees events inside a
session. The mirror gets a session-independent feed, so Observation works with or
without NGN attached.
### 5.5 Guarantees
- **One authority:** enforced structurally — the presentation branch never
constructs the mutation path's callers.
- **Late join:** `attach` → owner replies with the live
`contractAdapter.getContractState()` plus transient presentation extras.
- **Reload:** a reload is a fresh `attach` with a fresh `participantId`; identical
path to first join.
- **Prompt propagation:** `onLocalChange` fires inside `_commitChanges`, i.e. in
the same turn as the mutation.
- **Surface interactions:** routed as `mutate` and executed by the owner through
`contractAdapter.applyMutation(..., 'ui')` — the same chokepoint NGN's `set`
uses, satisfying §31.7's mutation/revision/source/event requirement.
- **No NGN-specific logic:** the bus is unaware NGN exists.
---
## 6. Audio / AI / procedural subsystem ownership
Audio is already safe by construction: `AudioManager`'s constructor
(`audio.js`) sets `this.ctx = null` and creates nothing; the `AudioContext` and
every node appear only in `init()`, reached only via `resume()`. So a presentation
instance that never engages transport never creates an audio graph. The dangerous
paths are the ones that *call* `resume()` or `prepareExperience()`.
| Subsystem | Authoritative console | Observation surface | Note |
| --- | --- | --- | --- |
| `AudioManager` / `AudioContext` | **Yes** | Constructed, **never `init()`/`resume()`** | Needed only to satisfy `ObservationEngine`'s constructor arity. |
| Hull / Warp / LifeSupport / Telemetry / Alert / Whataverse / Expanded / EngineTransition synths | **Yes** | Constructed, silent | Same reason; they create nodes only on play. |
| `FuturisticNoiceCancellation` | **Yes** | Constructed, inert | Do not call `setEnabled`/`setLevel` locally. |
| `XZBTGenerativeExperience` (WebLLM + Kokoro) | **Yes** | **Not constructed. Not bound. Never prepared.** | `enterObservation()` calls `prepareExperience()`**this call must be skipped in presentation mode**, or every opened pane downloads a 1.7B model. Highest-value guard in this design. |
| `XZBTContractAdapter` + window bridge | **Yes — the one session** | **Not constructed** | Guarantees no second contract session. |
| `XZBTControlBus` | **Yes** | Not constructed | |
| `StarshipVisualizer` (spectrum, warp core) | **Yes** | Not constructed | Its canvases are console-panel only. |
| `CoreAnimations` | **Yes** | Not constructed | |
| Sleep timer | **Yes** | Not constructed | |
| Hotkey handler (`window keydown`) | **Yes** | **Not installed** | A keystroke on the pane must not mutate state; see §7. |
| Ambient activity scheduler (`scheduleObservationAmbientActivity`) | **Yes — authority only** | **Disabled**; renders broadcast events | Prevents duplicate random event generation. |
| `triggerObservationActivity` | Decides + broadcasts | Renders on receipt | |
| AI announcement / ticker text | **Yes** (`onGenerated`) | Renders relayed text | Relayed as `presentation/ticker`; announcement text is not contract state. |
| `ObservationEngine` (canvas, bezels, waveform) | Yes (when overlay active) | **Yes — renderer only** | `am.analyser` guards already exist (`observation-engine.js:2262, 3097`). |
| Observation SVG scene, spline morphs, scene entity loop | Yes (when overlay active) | **Yes — renderer only** | Deterministic from universe + preset + activity. |
| Live instrumentation digit churn (`observationInstrumentTick`) | Yes | **Yes — local, deliberately** | See below. |
| `ObservationBezels` | Yes | Yes | Pure SVG generation. |
| Web3D / external visual deps | None exist | None | SciFi is canvas + SVG only. |
### Two judgment calls, stated explicitly
**1. Instrumentation digit churn stays local.** `observationInstrumentTick` mutates
cosmetic numerals in the HUD text. It is high-frequency, carries no narrative or
contract meaning, and mirroring it would need a continuous high-rate channel for
nothing. Two screens showing different filler digits is invisible. This is a
deliberate, bounded divergence — and it is the *only* one.
**2. Transient activity events are mirrored, not regenerated.** By contrast,
`triggerObservationActivity` spawns visible narrative beats (contacts, expanding
rings, text cards). Regenerating them independently would give two screens
different events and would violate the brief's "no duplicate random event
generation". The authority decides; the surface renders.
### The audience-scheduler correction (mandatory)
`triggerObservationActivity` begins `if (!observationActive …) return;` and
`scheduleObservationAmbientActivity` refuses to schedule unless `observationActive`.
So with the console in normal mode and only the NGN Observation pane open,
**nothing would ever be generated** — the surface would render a static scene.
6.7B must decouple the scheduler from local overlay visibility:
```js
const observationAudienceActive = () => observationActive || surfaceOwner.attachedCount() > 0;
```
Use that predicate for *scheduling and deciding* activity; keep `observationActive`
for *local rendering* only. The activity decision is then broadcast, and the
console renders it locally only when its own overlay is up.
### Audio-reactive fidelity (accepted limitation)
With no audio graph, `am.analyser` is null on the surface, so the waveform sill
and the audio-reactive modulation read zero. `ObservationEngine` already guards
this and degrades to a flat trace; the scene itself is procedural and unaffected.
Mirroring a scalar audio level is a reasonable future refinement and is **out of
scope for 6.7**.
---
## 7. Observation interaction policy
**Policy: minimally interactive — every retained control is a mutation request to
the authority; nothing is applied locally.**
Strict read-only would be a regression (the dock already exists and is genuinely
useful on a second screen). Fully interactive would mean installing hotkeys and
local setters on a non-authoritative document. The middle is both correct and
already half-built: four of the six dock controls *already* route through
`observationEngine.onMutation`.
| Control (`index.html`) | Decision | Mechanism |
| --- | --- | --- |
| `#obs-btn-warp` (WARP) | **Route** | Needs a new target — `view.warp-flight`. Today `toggleWarpFlight()` mutates engine-local state and would diverge. |
| `#obs-btn-frame` (VIEWPORT) | **Route** | Existing `view.viewport-frame`; already routed. |
| `#obs-btn-pillars` (PILLARS) | **Route** | Needs a new target — `view.pillars`. Today `togglePillars()` is engine-local and would diverge. |
| `#obs-btn-alert` (RED ALERT) | **Route** | Existing `alert.active`; already routed. |
| `#obs-select-preset` (PRESET) | **Route** | Existing `preset.selected`; already routed. |
| `#obs-btn-exit` (RETURN ✕) | **Remove** on the surface | Hidden by CSS. Closing a pane is NGN's job; posting `view.observation=false` from the pane would toggle the *console's* overlay — a confusing cross-surface side effect. |
| Background click-to-exit (app.js:4508) | **Remove** on the surface | Same reason; also makes the pane fragile to stray clicks. |
| `#observation-return-pill` | **Remove** on the surface | Same. |
| HUD HOLD (`#toggle-observation-hud`) | **Surface-local** | A per-screen fade preference, stored only in the checkbox. §31.7 explicitly leaves purely presentational surface-local interaction outside the contract. |
| ACTIVITY slider (console panel) | Console-only | Existing `view.activity`; the surface follows it. |
| Hotkeys | **Not installed** on the surface | Would be a local mutation path. |
Removal is by CSS on the body class, so `observation-engine.js` needs no change —
it may keep binding listeners to elements the user can never reach.
---
## 8. Observation lifecycle semantics
Four concepts, deliberately kept distinct:
| Concept | Owner | Meaning | Changed by |
| --- | --- | --- | --- |
| **Surface lifecycle** | NGN (`LocalSurfaces`) | Whether an Observation pane exists and is loaded | Operator clicking Open/Close/Reload in NGN |
| **`view.observation`** | SciFi console instance | Whether the **console page's own** full-screen overlay is up | WATCH EXPERIENCE, background click, Escape, contract `set` |
| **Fullscreen state** | Browser | Whether a document occupies the screen | User/OS; SciFi does not drive it |
| **Local overlay CSS state** | Each document | `.active` on `#observation-overlay` | Derived from the two above, per document |
### The explicit answer
> **Opening the secondary surface does NOT imply `view.observation = true`.**
> "A surface exists" and "Observation mode is active" are separate facts.
Rationale: `view.observation` is declared `restorable: false` and is scoped to the
console's own presentation. If opening the pane set it true, the operator's console
would black out into its overlay the instant they opened the pane — destroying
simultaneous control and observation, the whole point of §31.1. Conversely,
closing the pane must not set it false, or an operator using the console overlay
would lose it when tidying up NGN panes. Contract 5.3 is additive; no existing
target may be reinterpreted (`AGENTS.md`), and this keeps `view.observation`'s
meaning exactly as it is today.
Consequences, all of which 6.7B must honor:
- The Observation **surface renders the scene whenever it is open**, with no state
flag gating it. It is an Observation surface; that is what it is for.
- **WATCH EXPERIENCE is unchanged** — same button, same target, same local overlay.
- A host `set view.observation true` still raises the *console's* overlay and is
reflected on the surface only as a state readout. It never opens or closes a pane;
NGN owns pane lifecycle and Contract 5.3 gives an exhibit no way to ask for one.
- Closing the pane detaches cleanly: `window.__xzbtSurfaceDispose` (called
synchronously by `local-surfaces.js` before frame removal) plus a `beforeunload`
fallback, both idempotent. The owner removes the participant; **no state mutates.**
- When the last participant detaches and the console overlay is down,
`observationAudienceActive()` goes false and the ambient scheduler stops — the
symmetric counterpart of §6's correction.
---
## 9. Standalone behavior
Unconditionally preserved, and mostly by construction.
- **`index.html` opened with no query string is byte-for-byte today's application.**
The mode resolver returns `console` for the absence of `?surface=`, and the
console path is the existing code path. No behavior is gated on NGN.
- **Host attachment is not part of startup.** It is not today (the adapter only
installs a passive `message` listener) and must not become so. No `hello` is
ever initiated by SciFi.
- **In-page Observation is retained exactly.** WATCH EXPERIENCE, Escape,
background click, the dock, and the hotkeys all keep working with no NGN, no
surface, and no bus participant.
- **The renderer is shared, not duplicated.** Both modes run the same
`app.js`/`observation-engine.js` code; only the boot mode differs. There is no
second implementation to drift.
- **`?surface=observation` is directly usable without NGN** (§31.8's SHOULD): open
it in a second browser window over `http(s)` and it attaches to the console tab
on the same origin. With no console open it shows a waiting state and never
invents its own authority — Museum's proven behavior.
- **Single-file build:** opening `SciFiAmbientDisplay_V<commit>.html` normally is
unchanged. Under `file://`, `BroadcastChannel` is scoped to an opaque origin, so
the two-window pairing is **not** promised there; the single-file build's
Observation remains the in-page overlay. That is the correct guarantee, and it is
exactly what §31.8 requires (primary unconditional, non-primary best-effort).
---
## 10. Packaging approach
**No second application, no second document, no build change.**
| Concern | Impact |
| --- | --- |
| Source files | +2 small JS files (`js/surface-mode.js`, `js/surface-bus.js`); edits to `index.html`, `js/app.js`, `js/contract-adapter.js`, `css/style.css`. |
| Generated HTML | Still one file, `dist/SciFiAmbientDisplay_V<commit>.html`. |
| Optional secondary page | **None. Deliberately.** The Observation surface is a query-string view of the same document (§31.4). |
| Shared scripts | 100% shared — the surface *is* the same script set. Divergence is structurally impossible. |
| Build process | `tools/package.ps1` unchanged. The two new tags are inlined automatically **only if written in the exact form** `<script src="js/surface-mode.js"></script>` — the regex is whitespace- and quote-sensitive and fails silently otherwise (`agents.md`, "Adding a new JS file"). |
| Offline behavior | Unchanged. Nothing new touches the network. `BroadcastChannel` is a browser primitive with no dependency. |
| Future embedded offline package | Unaffected — still one artifact. |
| Resource duplication | The surface re-parses the same cached bytes and constructs the inert subsystem shells; it downloads nothing new and runs no second engine. |
Script order in `index.html` — insert **after `control-bus.js`, before
`generative-experience.js`**, so the adapter and `app.js` can see both globals:
```
audio.js → config.js → core-animations.js → visualizer.js →
observation-bezels.js → observation-engine.js → control-bus.js →
surface-mode.js → surface-bus.js →
generative-experience.js → contract-adapter.js → app.js
```
---
## 11. Contract adapter changes
All in `js/contract-adapter.js`. Additive only. No change to the handshake, the
envelope, message types, or generic semantics.
**1. Surface catalog.** Accept `options.surfaces` (an array) and
`options.instanceId`; store them. Emit `surfaces` from `describe()` **only when
non-empty**, so absence stays §31.3 form 1:
```js
describe() {
const description = { exhibit: {...}, contract: {...}, registryRevision, stateRevision,
capabilities: [...], targets: [...] };
if (this.surfaces && this.surfaces.length) description.surfaces = this.surfaces;
return description;
}
```
The array is a plain static declaration with exactly one `primary: true`.
Host-side §31.3 validation is NGN's job, not the exhibit's; do not reimplement it.
**2. Two missing absolute state targets.** Required for deterministic presentation
sync — without them, PILLARS and WARP diverge between console and surface, and a
host cannot read or restore them at all:
```js
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'] });
```
Permitted by §28.7 (additive targets). Wire matching entries in
`bindings.setters` (→ `observationEngine.setPillars` / `setWarpFlight`) and in
`getContractStateSnapshot()` (→ `observationEngine.showPillars` and the engine's
warp-flight field — confirm its exact name when implementing). `registryRevision`
stays `1`; this is a new build, not a runtime registry change, so no
`registry.changed` is emitted.
**3. Session-independent local hooks.** Add `onLocalChange` / `onLocalAction` as
described in §5.4. This is the only structural addition and it changes no
contract-visible behavior.
**4. Identity.** `app.js:126` passes `version: '5.2.0'`; bump to `'5.3.0'` to
match the adapter's declared `contractMinor = 3` and this build's surface support.
Advisory metadata only.
**No second command protocol.** Surface-originated interactions reuse `set` /
`invoke` semantics through `applyMutation` / `invokeAction`. **No capability
change** — `observation` is already declared `ready`.
---
## 12. NGN changes
### None.
Verified against the current source rather than assumed:
- **Query-string surface URLs already work.** `src/surface-url.js` accepts a
relative reference with a query and rejects only schemes, protocol-relative and
cross-origin values. `tests/local-surfaces.test.js:153` is literally
*"a separate primary page and query/fragment views are rendered from generic
descriptors"* — the case SciFi needs is already covered.
- **The resource check passes.** `server/serve.js` routes on `url.pathname` and
ignores the query, so `HEAD index.html?surface=observation` returns 200 and
`checkSurfaceResource` is satisfied.
- **Primary reuse already works.** `src/local-surfaces.js:35` compares full URLs
and marks the matching primary as the control pane, so it is never re-opened.
`tests/local-surfaces.test.js:54` covers it with Museum's real descriptors.
- **Lifecycle already works.** `release()` calls `__xzbtSurfaceDispose` synchronously
before removing a frame (`src/local-surfaces.js:51`); SciFi implements the hook.
- **Catalog validation already works.** `host.js:154` runs `validateSurfaceCatalog`
against §31.3 ordering, exercised by `tests/museum-gallery.test.js:172253`.
If a genuine NGN gap emerges during 6.7B, it must be fixed **generically** and
justified against Museum Gallery; a SciFi-specific branch is not an acceptable
outcome (`AGENTS.md`: "Do not infer XZBT-NGN behavior from SciFi-XZBT source code").
---
## 13. File-by-file implementation plan
### A. SciFi-XZBT — `G:/.vibe/SciFi-XZBT` (the real exhibit)
| # | File | Why it changes | Responsibility added | Must not change |
| --- | --- | --- | --- | --- |
| A1 | `js/surface-mode.js` **(new, ~60 lines)** | A pure, testable seam for mode resolution and the catalog | `XZBTSurfaceMode.resolve(locationLike) → { mode, instanceId }`; `XZBTSurfaceMode.newInstanceId()`; `XZBTSurfaceMode.SURFACES(instanceId)`; `XZBTSurfaceMode.channelName(instanceId)` | No DOM, no globals beyond `window.XZBTSurfaceMode`, no imports. Unknown `?surface=` values resolve to `console`. |
| A2 | `js/surface-bus.js` **(new, ~180 lines)** | Exhibit-internal state mirror | `createOwner(adapter, { channelName, onParticipantsChanged })` and `attach({ channelName, timeoutMs, onSnapshot, onChange, onAction, onPresentation, onTimeout })`, plus idempotent `detach()` and `mutate(kind, target, valueOrArgs)` | Carries no XZBT envelope. `Set`-based participant bookkeeping (not a counter). Owner routes every `mutate` through `adapter.applyMutation/invokeAction` with source `'ui'` — never a second computation of state. |
| A3 | `index.html` | Load the two new files | Two `<script src>` tags in the exact `package.ps1` form, positioned per §10 | **No DOM changes.** The overlay markup, dock and all IDs stay exactly as they are — the design depends on them existing in both modes. |
| A4 | `js/contract-adapter.js` | Advertise surfaces; close the two target gaps; expose session-independent hooks | §11 items 14 | Handshake, envelope, `xzbt` advisory handling, inbound gating, error codes, `_emitEvent`'s session gate, existing target IDs and semantics. |
| A5 | `js/app.js` | Mode branch, owner/mirror wiring, audience scheduler | (a) resolve mode at the top; add `body.surface-observation` in presentation mode. (b) **Presentation branch skips:** `XZBTGenerativeExperience` construction/bind, `StarshipVisualizer`, `CoreAnimations`, `XZBTControlBus`, `XZBTContractAdapter`, the `keydown` handler, the sleep timer, and all console-panel listeners. (c) **`enterObservation()` must not call `prepareExperience()` in presentation mode.** (d) Presentation enters the scene on snapshot and re-runs `refreshObservation()` on universe/preset/activity change. (e) Replace `observationActive` with `observationAudienceActive()` for **scheduling/deciding** activity only. (f) Broadcast activity decisions and ticker text; presentation renders them and runs no scheduler. (g) Presentation dock routes through `bus.mutate`. (h) `window.__xzbtSurfaceDispose = () => link.detach()` + `beforeunload`. (i) Console mode constructs the bus owner and feeds it from `onLocalChange`/`onLocalAction`. | The console code path's behavior. `getContractStateSnapshot()`'s shape (extend with the two new keys only). The single mutation chokepoint. `agents.md`: surgical scoped edits, no reformatting. |
| A6 | `css/style.css` | Present the surface mode | `body.surface-observation`: hide `header/main/footer`, show the overlay full-bleed, hide `#obs-btn-exit` and `#observation-return-pill` | Existing theme, overlay and CRT rules. Additive block only. |
| A7 | `agents.md` | Keep the agent contract honest | Document the one-document/two-mode rule, the two new files, the bus, and the "never construct audio/AI in presentation mode" invariant | The hard constraints; they are all still satisfied. |
| A8 | `README.md` | User-facing | One short paragraph on the Observation surface | — |
**Deliberately unchanged:** `js/observation-engine.js`, `js/observation-bezels.js`,
`js/audio.js`, `js/config.js`, `js/visualizer.js`, `js/core-animations.js`,
`js/generative-experience.js`, `js/control-bus.js`, `tools/package.ps1`, `dist/**`
(regenerate, never edit).
### B. XZBT-NGN — `G:/.vibe/XZBT-NGN`
| # | File | Why it changes | Responsibility added | Must not change |
| --- | --- | --- | --- | --- |
| B1 | `test-fixtures/reference-exhibits/scifi/**` | Fixture must match the new exhibit | Mechanical resync of the changed files + the two new ones | Nothing hand-edited; keep it a faithful copy. |
| B2 | `test-fixtures/PROVENANCE.md` | Provenance discipline | Note the resync, its source commit, and that no fixture-local corrections were added | The existing correction record. |
| B3 | `tests/scifi-surfaces.test.js` **(new)** | Cover the new seams | §14 tests, in the existing `node:test` + `vm.createContext` + real `BroadcastChannel` style (`tests/museum-gallery.test.js`) | — |
| B4 | `tests/local-surfaces.test.js` | Prove the real SciFi descriptors render correctly | One case using SciFi's actual catalog, mirroring the Museum cases at :54 and :153 | Existing cases. |
| B5 | `docs/reference/XZBT-NGN-Step6.7-SciFi-Surface-Adaptation.md` **(new)** | Verification writeup | Evidence for 6.7B, matching the Step 6.6 document's shape | — |
| B6 | `README.md` | Operator accuracy | Note that NGN should be pointed at the explicit `…/scifi/index.html` URL (risk R1) | — |
**`src/**` — no changes.** If a diff appears there in 6.7B, it is a design
deviation and needs review before merge.
---
## 14. Automated test plan for Step 6.7B
`node --test`, matching the existing style: `vm.createContext` per simulated
document, Node's real `BroadcastChannel`, `plain()` for cross-realm comparison,
and every channel closed in teardown. `app.js` is far too DOM-bound for `vm`;
that is why A1/A2 are deliberately small, pure and separately loadable, and why
the app-level invariants below are asserted as **source-structure tests** — the
same technique as Museum's "Exactly one Exhibit State Core exists".
**Catalog and contract (`tests/scifi-surfaces.test.js`)**
1. `describe()` includes a `surfaces` array that passes `validateSurfaceCatalog`
against a representative base URL.
2. Exactly one entry has `primary: true`.
3. Every entry carries only §31.2 fields; no invented fields; `kind === 'surface'`.
4. Constructing the adapter with no `surfaces` option omits the field entirely
(§31.3 form 1 preserved — the Museum analogue at `museum-gallery.test.js:148`).
5. `resolveSurfaceURL('?surface=observation&xi=…', base)` resolves same-origin to
`base?…`; absolute/protocol-relative variants are rejected.
6. The primary's `url` resolves to exactly a base of `…/scifi/index.html`, so
`LocalSurfaces` marks it `control` and opens no second frame (extend
`tests/local-surfaces.test.js`).
7. `view.pillars` and `view.warp-flight` exist, are `state`/`boolean`, readable,
writable, and appear in the snapshot key set.
**Mode resolution (pure)**
8. No query → `console`. `?surface=observation``observation`.
`?surface=bogus``console`. Query order and extra params are tolerated.
9. `xi` round-trips through `SURFACES()``resolve()` and `channelName()` is
instance-scoped (two ids yield different names).
**Bus / synchronization**
10. **Late join:** mutate first, then attach — the snapshot carries current
universe, preset and view state, plus the current `stateRevision`.
11. **Propagation:** an authority mutation reaches an attached surface as one
`state`/`selection` message with the post-mutation value.
12. **Mutation routing:** `link.mutate('set','view.pillars',true)` results in
exactly one `adapter.applyMutation` call with source `'ui'`, and the value
converges on the authority and on a *second* attached surface.
13. **No second authority:** a surface-side `mutate` performs no local state write;
with no owner present it is a no-op and the surface enters the waiting state
(the analogue of `museum-gallery.test.js:469`).
14. **One event stream:** `stateRevision` advances monotonically regardless of
which surface originated the interaction; two attached surfaces never disagree.
15. **Reload:** re-attaching with a fresh `participantId` yields the current
snapshot; repeated reloads leave the participant count stable (no ratchet).
16. **Detach:** idempotent; never mutates state; count decrements exactly once;
duplicate/late `detach` cannot underflow.
17. **Session independence:** with `sessionActive === false`, `onLocalChange` still
fires on `_commitChanges` — the correction in §5.4 — while `_emitEvent` still
emits nothing.
**Duplicate-subsystem prevention (construction spies + source structure)**
18. *Spy test:* load `surface-bus.js` in a `vm` context with stub globals and
assert the surface side never touches `AudioContext`, `fetch` or `import`
a counting stub for `globalThis.AudioContext` must record zero constructions.
19. *Source-structure test:* assert `js/app.js` contains no call to
`prepareExperience()` that is not inside the console-mode branch — pin it by
asserting the presentation branch is entered before the
`new XZBTGenerativeExperience` / `new XZBTContractAdapter` /
`new StarshipVisualizer` construction sites, in the manner of Museum's
"structurally incapable" assertion. Prefer a resilient marker (e.g. a named
`bootConsole()` function containing those constructions) over brittle regex —
**A5 should be implemented so this test is easy to write.**
20. *Timer test:* with only a presentation instance simulated, no ambient-activity
timer is scheduled.
21. **One contract session:** the presentation branch never installs a `message`
listener; a simulated `hello` posted at it produces no `hello.result`.
**Regression**
22. `tests/museum-gallery.test.js`, `tests/local-surfaces.test.js`,
`tests/surface-validation.test.js`, `tests/host.test.js`,
`tests/connection.test.js`, `tests/postmessage-interop.test.js`,
`tests/haunted-house.test.js` all remain green (`npm test`).
23. `tests/postmessage-interop.test.js:118` still negotiates a SciFi session
against the resynced fixture.
---
## 15. Live acceptance procedure
Serve with `npm start` (`http://127.0.0.1:4173`).
1. Open NGN at `http://127.0.0.1:4173/` and connect to
**`http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html`**
(the explicit filename — see R1).
2. Confirm `connected`, Contract major 5 / minor 3, target catalog populated.
3. In the Surfaces panel: exactly two entries. `surface.console` is badged
**Primary** and offers **no Open button** (it is the control pane).
`surface.observation` is non-primary with `URL: ?surface=observation&xi=…`.
4. Open `surface.observation`. It loads in its own pane and renders the current
universe and preset within ~1s.
5. Confirm the pane shows **no RETURN ✕ and no return pill**, and that clicking
its background does nothing.
6. Confirm the console pane is **still the full console**`view.observation`
remains `false` and the console did not black out. *(This is the §8 invariant.)*
7. From NGN, `set universe.selected` to another universe. Both the console and the
Observation pane change scene. Repeat for `preset.selected`, `alert.active`,
`view.viewport-frame`, `view.activity`.
8. On the Observation pane, click **PILLARS** and **WARP**. The value changes on
the pane *and* on the console's own controls, and NGN's state readout advances
`stateRevision` — proving the mutation went through the authority, not locally.
9. Click **RED ALERT** and change **PRESET** on the pane; confirm the same
convergence across console, pane and NGN.
10. Reload the Observation pane from NGN. It returns to the *current* state, not a
default one.
11. Close the pane, then reopen it. Same result; the participant count in the
console does not ratchet.
12. On the console, press **WATCH EXPERIENCE**. The console overlay comes up,
`view.observation` becomes `true`, and the Observation pane is **unaffected**
(it was already rendering).
13. Exit the console overlay. `view.observation` returns to `false`; the pane is
still rendering. Repeat once.
14. **Duplication checks, with the pane open and the console engaged (audio on):**
- DevTools → the pane's frame → console shows no WebLLM/Kokoro network
activity, and `window.generativeExperience` is `undefined`.
- The pane's frame has no `AudioContext` (audio comes only from the console);
mute the console and the room goes fully silent.
- Transient activity events (contacts, rings, cards) appear on console overlay
and pane **at the same moment and as the same events**, not independently.
- An AI announcement appears once, on both tickers, with identical text.
15. Disconnect NGN. The pane is released and the console continues standalone.
Reconnect; surfaces rediscover and the pane reopens cleanly.
16. **Standalone, no NGN:** open
`…/test-fixtures/reference-exhibits/scifi/index.html` directly. Full console,
clean browser console, WATCH EXPERIENCE works, hotkeys work, audio engages.
17. **Standalone two-window:** with that tab open, open
`…/index.html?surface=observation&xi=<id from the console's describe()>` in a
second window. It attaches and mirrors with no NGN present.
18. Open `…/index.html?surface=observation&xi=nonexistent` alone. It shows a
waiting state and does **not** invent its own state or start audio.
19. Re-run `tools/package.ps1`; open the new `dist/` file **with the network
disabled**. Console, Observation overlay, audio and hotkeys all work; the
Generative Experience panel degrades quietly. *(The packaged build's
Observation is the in-page overlay; two-window pairing is not promised under
`file://` — §9.)*
---
## 16. Risks and mitigations
**R1 — A directory-style base URL causes the primary to open a second full
instance.** `LocalSurfaces` uses full-URL equality. If the operator connects to
`…/scifi/` rather than `…/scifi/index.html`, `exhibitBaseUrl` ends in `/`,
`index.html` no longer equals it, and NGN offers to open the primary as a pane —
which would load a second complete SciFi app.
*Impact:* bounded. That instance gets no transport from NGN and its window bridge
only answers parent/opener/self, so **no second contract session** is created, and
it creates no `AudioContext` until someone engages it. It is wasted, not wrong.
*Mitigation:* document the explicit-filename URL in `README.md` (B6) and in the
6.7B verification doc; it is already the documented URL. Do **not** "fix" this in
NGN — full-URL equality is deliberate (queries and fragments designate other
views), and Museum has the same property. Revisit generically in a later step if
it proves to bite operators.
**R2 — The `prepareExperience()` guard is missed.** `enterObservation()` calls it
unconditionally today; a presentation instance that reaches that line downloads a
1.7B model per pane.
*Mitigation:* the single most important guard in A5(c); covered by tests 1819 and
acceptance step 14. Structure A5 so the construction sites sit in a named
console-only function, making the assertion cheap and durable.
**R3 — The ambient scheduler silently never runs.** Both
`triggerObservationActivity` and `scheduleObservationAmbientActivity` early-return
on `!observationActive`; with the console in normal mode, an open pane would show
a static scene and the defect would look like "the surface is just quiet".
*Mitigation:* the `observationAudienceActive()` correction (§6), test 20, and
acceptance step 14's "same moment, same events" check, which fails loudly if
nothing is generated.
**R4 — Mode branching damages the console path.** `app.js` is 4,703 lines of
tightly coupled closure; a badly placed branch could disable console features.
*Mitigation:* branch by *skipping construction* in presentation mode rather than
by adding conditionals through the console path; `agents.md`'s surgical-edit rule;
acceptance steps 1619 exercise standalone fully; keep the diff reviewable.
**R5 — BroadcastChannel cross-talk between instances.** Two console tabs on one
origin would share a fixed channel name.
*Mitigation:* the instance-scoped channel name (§5.2), tests 9 and 18.
**R6 — Fixture drift between SciFi-XZBT and the NGN copy.** Two trees, one
mechanical copy, and both currently carry uncommitted work.
*Mitigation:* resync as one discrete step (B1), record it in `PROVENANCE.md` (B2),
and hash-verify as Step 5 did (`test-fixtures/evidence/step5-scifi-files.json`).
**R7 — `package.ps1`'s silent inlining failure.** A script tag with different
quoting, extra attributes or self-closing form is left as an external reference and
the standalone build breaks with no error.
*Mitigation:* A3 specifies the exact form; acceptance step 19 opens the packaged
build offline, which is the only test that catches it.
**R8 — Audio-reactive visuals are flat on the surface.** No analyser without an
audio graph.
*Mitigation:* accepted and documented (§6). `ObservationEngine` already guards it.
Mirroring a scalar level is deferred; do not solve it in 6.7B.
**Explicitly not risks:** a second contract session (structurally prevented twice
over); NGN genericity (no NGN change); loss of standalone behavior (the console
path is untouched); packaging fork (one document by construction).
---
## 17. Step 6.7B scope estimate
### Moderate.
Not *small*: the work spans two repositories, touches the largest and most
sensitive file in SciFi-XZBT (`app.js`), adds two contract targets and a catalog,
introduces two new source files and a new synchronization mechanism, and needs a
fixture resync plus two test files and a verification document. Three of the
corrections it depends on — the `_emitEvent` session gate, the
`prepareExperience()` call inside `enterObservation()`, and the
`observationActive` gate on the ambient scheduler — are non-obvious and would each
produce a plausible-looking but wrong implementation if missed.
Not *large*: the decisive cost driver was avoided. There is **no renderer
extraction, no DOM decoupling, no second document, no build change, and no NGN
change.** `observation-engine.js` (3,595 lines) and `audio.js` (6,889 lines) are
untouched. The synchronization mechanism is a direct adaptation of Museum
Gallery's already-validated `surface-bus.js`, and the NGN side was proven in Steps
6.36.6.
Expected diff: roughly 240 new lines across two new SciFi files, ~120 modified
lines in `app.js`, ~40 in `contract-adapter.js`, ~25 in `css/style.css`, 2 lines
in `index.html`, plus ~350 lines of new tests and the fixture resync.
The risk concentrates almost entirely in A5. Implement A1 and A2 first, test them
in isolation, and only then branch `app.js`.
@@ -1,6 +1,13 @@
# XZBT Exhibit Contract Specification
## Version 5.2
> **Historical:** Superseded, additively, by
> [Contract Specification 5.3](XZBT-Exhibit-Contract-Specification-v5.3.md),
> the current maintained contract. No normative requirement in this document
> was removed, weakened, or reinterpreted by 5.3; it is retained here for
> historical context, not as a second currently-maintained contract version.
> XZBT-NGN and its maintained reference exhibits target Contract 5.3.
**Status:** Proposed normative specification
**Document version:** 5.2
**Contract family:** XZBT Exhibit Contract
@@ -0,0 +1,256 @@
# XZBT-NGN Step 6.6 — Reference Exhibit End-to-End Validation
Status: PASS — no implementation changes were required.
Step 6.6 validates Museum Gallery end-to-end as a complete Contract 5.3 reference
exhibit operating through the normal NGN operator UI. It is a validation task: no
architecture was redesigned, no features were added, and no source file was modified.
## A. Environment
| Item | Value |
| --- | --- |
| NGN UI | `http://127.0.0.1:4173/` |
| Server | The already-running `npm start` server on port 4173 (started at 16:43, before this session). A second instance started for validation failed to bind with `EADDRINUSE` and exited, so the served state was unchanged and the running server was left untouched. |
| Exhibit under test | `http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html` |
| Replacement exhibit | `http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html` |
| Working tree | Step 6.3/6.4/6.5 uncommitted work preserved as found; HEAD `ed76cf6` |
| Method | Real NGN UI in a browser, driven through the served page (no test doubles) |
## B. Initial connection
Observed through the served NGN page after `Load exhibit`:
- Status: `connected · synchronized`
- Session: `sess-1f54f`
- Negotiated contract: `{ major: 5, minor: 3 }`
- Exhibit metadata: `Museum Gallery · 0.1.0 · build reference-exhibit`
- `registryRevision` 1, `stateRevision` 0
- Capabilities: `render: ready`
- Target registry discovered (5):
| Target | Kind | Access |
| --- | --- | --- |
| `artifact.selected` | selection | readable · writable |
| `lighting.level` | range | readable · writable |
| `rotation.speed` | range | readable · writable |
| `labels.enabled` | state | readable · writable |
| `action.spotlight-flash` | impulse | not readable · not writable |
- State snapshot received: `artifact.selected="the-orrery"`, `lighting.level=0.6`,
`rotation.speed=0.4`, `labels.enabled=true`
- Protocol/error log: one `SESSION` entry, no errors
## C. Surface discovery and primary handling
`Presentation surfaces (3)` discovered, exactly the three the exhibit advertises:
| id | Label | Primary | Role | URL |
| --- | --- | --- | --- | --- |
| `surface.control` | Control Room | yes | control | `control.html` |
| `surface.artifact` | Artifact Display | no | ambient | `artifact.html` |
| `surface.info-wall` | Information Wall | no | information | `info-wall.html` |
Primary handling: the primary is not duplicated as a local pane. Its card carries the
`Primary` badge, shows `Open in the authoritative control frame. Use Connection controls
to reload or disconnect.`, and its Open/Reload/Close buttons are disabled. The two
non-primary surfaces open as local panes and each renders and reports
`Attached to Control Room.`
The Control Room's own `Attached non-primary surfaces` readout (the exhibit's participant
count) became `2` when both panes were open.
## D. Lifecycle validation
Measured participant count in the authoritative Control Room:
```text
open both: 2
reload: 2
reload again: 2
close one: 1
reopen: 2
```
Each reload produced a fresh secondary document (verified by a marker placed on the
frame's `contentWindow` disappearing after reload), which then re-attached and rendered
the current authoritative state. No ratcheting and no stale participant entries were
observed at any point.
## E. Shared-state validation
### Control -> secondaries
| Change made in Control Room | Observed propagation |
| --- | --- |
| Selected artifact -> Star Map | `selection.changed`, sequence 1, `stateRevision` 1, `source: "ui"`; both secondaries rendered `Star Map` |
| Lighting level -> 25% | `state.changed`, sequence 2, `stateRevision` 2, `source: "ui"`; Artifact Display rendered `Lighting: 25%` |
Control Room, Artifact Display, Information Wall and NGN's reported state all agreed
after each change.
### Secondary -> primary -> secondaries
A real mouse click on the Artifact Display's native `Cycle artifact` control while the
authoritative selection was `star-map`:
- Control Room's authoritative selection became `meteorite` (its combobox followed)
- `selection.changed`, sequence 3, `stateRevision` 3, `source: "ui"`
- Information Wall updated to `Meteorite Fragment`
- Artifact Display re-rendered from the authoritative result
The secondary remained a mirror: it holds no Core (see section H) and only routes
mutations back through the owner's canonical path.
### Late join
Information Wall closed (count 1) -> authoritative state mutated (rotation 1.7,
labels OFF, `stateRevision` 5) -> Information Wall reopened: it immediately rendered
`Meteorite Fragment` and `Labels: OFF`, and the count returned to 2.
### Reload recovery
Lighting mutated to 90% (`stateRevision` 6) -> Artifact Display reloaded: a fresh
document (marker gone) returned showing `Meteorite Fragment`, `Lighting: 90%`,
`Rotation: 1.7x` — current state, not exhibit defaults — with the count still 2.
### Sustained convergence
Four consecutive normal changes alternating between the primary and both secondaries
(control selection, Information Wall toggle, control rotation, Artifact Display cycle)
left Control Room, Artifact Display, Information Wall and NGN all in agreement at every
step, with revisions advancing 7 -> 8 -> 9 -> 10 and the count steady at 2.
## F. Host-command validation (NGN UI)
| Operation | NGN control used | Result |
| --- | --- | --- |
| Readable state query | `Refresh state` (`state.get`) | Succeeded; snapshot matched, `stateRevision` 10, status `connected · synchronized`, no error |
| `set` (range) | `lighting.level` -> `0.35` + `Set` | Succeeded; Control Room 35%, Artifact Display `Lighting: 35%`, `state.changed` revision 11, `source: "host"`, `correlationId: ngn-2-4` |
| `set` (selection) | `artifact.selected` -> Meteorite Fragment + `Set` | Succeeded; all surfaces `Meteorite Fragment`, `selection.changed` revision 12, `source: "host"`, `correlationId: ngn-2-6` |
| `invoke` (impulse) | `action.spotlight-flash` + `Invoke` | Succeeded; `action.executed` sequence 13, `source: "host"`, `correlationId: ngn-2-8`; exhibit `spotlightFlashCount` incremented; Control Room and both secondaries flashed `Spotlight flashed.` |
Each command left NGN's own state synchronized and produced no error entry. Instrumenting
the Artifact Display's announcement node with a DOM observer confirmed the host-initiated
`action.executed` event actually reached the secondary (it flashed, then returned to idle
after the announcer's 2.6 s window).
## G. Reconnect and replacement
### Disconnect / reconnect
- `Disconnect`: status `disconnected · not synchronized`; catalog reset to
`Connect an exhibit to discover its controls.`; surfaces reset to
`No presentation surfaces advertised.`; both local panes removed (0 surface iframes
remaining); control frame removed; reported state `{}`; no error entries.
- `Reconnect`: new session `sess-15cf1`, Contract 5.3, all three surfaces rediscovered
with correct primary handling, control frame re-established, participant count `0`
(fresh authoritative document), no stale panes or counts.
- Both secondaries reopened and attached to the new session (count 2), each rendering the
new session's current state.
### Exhibit replacement
- Museum -> SciFi (`scifi/index.html`): no Museum pane survived (0 iframes referencing
the Museum exhibit), the surface catalog became `No presentation surfaces advertised.`
(SciFi advertises none), and SciFi negotiated Contract 5.3 as
`SciFi-XZBT · 5.2.0 · build production`, session `sess-2akzu3a7`,
`connected · synchronized`, 61 targets, state snapshot received.
- SciFi -> Museum (`museum-gallery/control.html`): new session `sess-126c6`, Contract 5.3,
5 targets, 3 surfaces rediscovered with the correct primary, participant count `0`,
0 SciFi frames left, 0 stale Museum panes.
## H. Failure isolation
Live-tested through the served UI:
- **A non-primary surface holds no authority.** Inside the Artifact Display document:
`XZBTContractCore` undefined, `MuseumGalleryContract` undefined,
`MuseumGalleryExhibit` undefined, `XZBTHostTransport` undefined; only the surface bus is
present, and `__museumGalleryDebug` exposes a `link` but no core.
- **A secondary cannot act as a protocol peer.** A forged `hello` and a forged `set`
posted directly to the Artifact Display's window produced zero responses, and the
authoritative selection was unchanged.
- **One broken presentation surface does not harm the control session.** The Artifact
Display frame was navigated to a missing page (`does-not-exist.html`, served as
`Not found`): the authoritative session stayed `connected · synchronized` on
`sess-126c6`, the Control Room kept rendering, the registry/state were unaffected, and
the surface's participant slot was released (count 2 -> 0) with no stale entry.
- **Reload failure is surface-local and recoverable.** NGN's per-surface `Reload` brought
the pane back to `artifact.html`; it re-attached and the count returned to 1 while the
session remained synchronized. No error was raised against the session.
Automated-only (covered by the suites below, not re-injected live):
- malformed / individually-invalid surface descriptors and catalog-level rejection
(`surface-validation.test.js`, `museum-gallery.test.js` — 50 validation cases)
- isolated HTTP, frame and reload failures, external frame removal detection, and a
stalled surface resource (`local-surfaces.test.js`)
- resource-check refusal of redirects before frame navigation (`local-surfaces.test.js`)
- presentation window cannot impersonate the authoritative peer over the production
transport (`local-surfaces.test.js`)
- source/origin validation on the real postMessage transport
(`postmessage-interop.test.js`)
## I. Automated verification
Focused run (Museum Gallery, local surfaces, surface validation, postMessage interop,
connection lifecycle):
```text
node --test tests/museum-gallery.test.js tests/local-surfaces.test.js \
tests/surface-validation.test.js tests/postmessage-interop.test.js tests/connection.test.js
# tests 107
# pass 107
# fail 0
```
Full suite (run once):
```text
node --test
# tests 129
# pass 129
# fail 0
```
Other validation:
```text
git diff --check -> clean (exit 0)
python devlog_editor.py --validate -> devlog-editor: devlog is valid (exit 0)
```
## J. Files changed
None. Validation found no defect, so per the Step 6.6 fix policy no implementation file
was modified. The only additions are this report and its raw evidence record
(`test-fixtures/evidence/step6.6-museum-gallery-validation.txt`).
## K. Limitations
1. Failure isolation was live-tested only for the three cases in section H; malformed
descriptor catalogs, redirect refusal, stalled resources and peer impersonation remain
automated-only, and no risky manual failure injection was performed for them.
2. The `Spotlight flashed.` announcement on secondaries is transient (2.6 s idle timeout).
It was confirmed with a DOM mutation observer rather than by a raw text read after the
event, so the recorded evidence is the observer transition sequence.
3. All live checks were same-origin over `http://127.0.0.1:4173`. Cross-origin exhibits,
`file://` documents, and non-`http` schemes were not exercised live.
4. The exhibit instance is recreated by `Disconnect`/`Reconnect` and by exhibit switching,
so post-reconnect state is the exhibit's documented default state (`stateRevision` 0).
No cross-session state persistence exists in the reference exhibit; this is expected
standalone behavior, not a regression.
5. Local surfaces were exercised in one browser window; casting/remote displays remain
out of scope for Step 6.6 and were not tested.
## L. Verdict
PASS. Museum Gallery behaves as a complete Contract 5.3 reference exhibit through the
normal NGN operator UI: connection and synchronization succeed, Contract 5.3 is
negotiated, the surface catalog and primary handling are correct, both secondaries render
locally and report attachment, participant counts stay correct across reload/close/reopen,
Control -> secondary and secondary -> primary -> secondaries synchronization are both
proven, late join and reload recovery return current state, representative host commands
work, disconnect/reconnect and exhibit switching clean up completely, SciFi still connects
and synchronizes, and the focused and full automated suites pass.
@@ -0,0 +1,206 @@
# XZBT-NGN Step 6.7B — SciFi Observation Surface Implementation
Status: PARTIAL PASS. Implementation, focused tests, and full NGN test suite are
complete against a pre-existing, out-of-scope defect. Live browser verification
(report §15) and the standalone packaged-build check were not performed in this
session.
Step 6.7B implements the architecture selected by Step 6.7A (design-only, no
code changes) — Candidate A, narrow form: SciFi-XZBT remains one document, one
script set, with two boot modes, and advertises a Contract 5.3 Observation
presentation surface as a query-mode view of its own `index.html`.
## A. Chosen architecture (confirmed as implemented)
- One document, two boot modes, resolved by `js/surface-mode.js` from
`window.location` before heavy subsystem construction.
- Primary surface: `index.html` (unchanged console), `primary: true`.
- Observation surface: `index.html?surface=observation&xi=<instanceId>`, per
Contract 5.3 §31.4's query-string surface form.
- One authoritative SciFi state, one Contract 5.3 host session (console mode
only), no NGN changes (`src/**` untouched — verified by mtime check against
this session's own edits).
- Presentation synchronization is exhibit-owned via an instance-scoped
`BroadcastChannel` (`js/surface-bus.js`), never observed by NGN.
- Opening the Observation surface does not set `view.observation = true`; the
console's own full-screen overlay is unaffected by pane lifecycle.
## B. Files changed — SciFi-XZBT (`G:\.vibe\SciFi-XZBT`)
| File | Change |
| --- | --- |
| `js/surface-mode.js` | **New**, 108 lines. Pure mode resolver (`resolve`, `newInstanceId`, `SURFACES`, `channelName`). No DOM, no imports. |
| `js/surface-bus.js` | **New**, 254 lines. Instance-scoped `BroadcastChannel` owner/attach sides (`createOwner`, `attach`), adapted from Museum Gallery's proven attach/snapshot/mutate/detach shape with a Set-based participant count. |
| `js/contract-adapter.js` | 1006 → 1060 lines (+54). Additive: `surfaces`/`instanceId` constructor options; `describe()` emits `surfaces` only when non-empty; `view.pillars` / `view.warp-flight` targets registered (state/boolean/readable/writable/restorable, `category: observation`, `requires: [observation]`); `onLocalChange`/`onLocalAction` hooks fired from `_commitChanges`/`_invokeAction`, independent of `sessionActive`; version bumped `5.2.0``5.3.0`. Handshake, envelope, `xzbt` gating, and the `_emitEvent` session gate are unchanged. |
| `js/app.js` | 4703 → 4921 lines (+218). Boot-mode resolution; `isConsoleMode`-guarded construction of `StarshipVisualizer`, `XZBTGenerativeExperience`, `XZBTControlBus`, `XZBTContractAdapter`, the keydown hotkey listener, and the sleep timer; `enterObservation()`'s `prepareExperience()` call now runs only in console mode; surface-bus owner wiring (console) / attach wiring (presentation); `applyPresentationState()` renderer; `observationAudienceActive()` decoupling the ambient scheduler from local overlay visibility; `view.pillars`/`view.warp-flight` added to `bindings.setters` and `getContractStateSnapshot()`; `togglePillars()`/`toggleWarpFlight()` now route through `applyMutation`/bus `mutate` instead of local engine mutation; `window.__xzbtSurfaceDispose` + `beforeunload` detach. |
| `js/observation-engine.js` | 3595 → 3602 lines (+7). Only change: the WARP and PILLARS dock buttons now route through `onMutation`, matching the pre-existing VIEWPORT/RED ALERT/PRESET pattern, instead of mutating engine state directly. |
| `css/style.css` | 2430 → 2457 lines (+27, additive). `body.surface-observation` rules hide `header`/`main`, hide `#obs-btn-exit` / `#observation-return-pill`; overlay renders full-bleed. |
| `index.html` | +2 `<script src="...">` tags, exact `package.ps1`-compatible form, positioned after `control-bus.js`, before `generative-experience.js`. |
| `agents.md`, `README.md` | Documented the two-mode rule, the two new files, the surface-bus mechanism, and the no-duplicate-authority invariant. |
`js/audio.js`, `js/config.js`, `js/visualizer.js`, `js/core-animations.js`,
`js/generative-experience.js`, `js/control-bus.js`, `js/observation-bezels.js`,
and `tools/package.ps1` are unchanged, per the report's "deliberately
unchanged" list.
## C. Files changed — XZBT-NGN
| File | Change |
| --- | --- |
| `test-fixtures/reference-exhibits/scifi/{index.html, js/app.js, js/contract-adapter.js, js/observation-engine.js, css/style.css}` | Byte-for-byte resync from SciFi-XZBT source. |
| `test-fixtures/reference-exhibits/scifi/js/{surface-mode.js, surface-bus.js}` | **New**, copied verbatim from SciFi-XZBT source. |
| `test-fixtures/PROVENANCE.md` | Appended resync note (source path, files copied, no fixture-local corrections). |
| `tests/scifi-surfaces.test.js` | **New**, 533 lines, 24 tests: surface catalog / contract validation, mode resolution, bus late-join/propagation/mutation-routing/no-second-authority/reload/detach/session-independence, duplicate-subsystem construction spies, app.js source-structure guards, ambient-timer test, one-contract-session test. |
| `tests/local-surfaces.test.js` | +1 test using SciFi's real descriptors (19 → 20 tests), confirming the primary resolves to `control` and opens no second frame. |
| `src/**` | **Unchanged.** No NGN core changes were needed or made. |
## D. State synchronization
- Mechanism: `BroadcastChannel` named `xzbt-scifi-surface-v1:<instanceId>`, owned
by the console document; the presentation document reads `instanceId` from its
own `?xi=` query parameter.
- Message shapes match report §5.3 verbatim (`attach` / `attach.snapshot` /
`state` / `action` / `presentation` / `mutate` / `detach`), carry no XZBT
envelope, and are never observed by NGN.
- Late join / reload: `attach` always receives the live
`contractAdapter.getContractState()` snapshot plus `stateRevision` and
`registryRevision`; a reload is a fresh `attach` with a fresh `participantId`.
- Presentation mutations (`view.pillars`, `view.warp-flight`, `view.viewport-frame`,
`alert.active`, `preset.selected`) are sent as `mutate` requests and applied by
the owner exclusively through `contractAdapter.applyMutation(...)` with source
`'ui'` — the same chokepoint NGN's own `set` uses. The presentation side never
writes local state.
- Participant bookkeeping is a `Set`, so duplicate attach and late/duplicate
detach are harmless and cannot ratchet the count.
## E. Heavy-subsystem ownership (confirmed)
Presentation mode does **not** construct: `XZBTGenerativeExperience` (WebLLM +
Kokoro — `prepareExperience()` is now called only inside the console-mode
branch of `enterObservation()`), `XZBTContractAdapter` (no second Contract
session), `XZBTControlBus`, `StarshipVisualizer`, or `CoreAnimations` (its
render loop is only reachable from `StarshipVisualizer`, which is never
constructed). The keydown hotkey listener and the sleep timer are also
console-only. This is enforced by `isConsoleMode` guards at each construction
site and asserted by source-structure tests in `scifi-surfaces.test.js`, plus
construction-spy tests that fail if `AudioContext`/`fetch`/`import` are ever
touched from the surface side.
`AudioManager` and the synth subsystems are still constructed on the
presentation side (needed only to satisfy `ObservationEngine`'s constructor
arity, per the 6.7A report) but never `init()`/`resume()`d, so no
`AudioContext` or audio node is ever created there.
## F. New absolute state targets
- `view.pillars` — state/boolean, readable, writable, restorable,
`category: observation`, `requires: [observation]`. Wired into
`bindings.setters`, `getContractStateSnapshot()`, and dock routing on both
surfaces.
- `view.warp-flight` — same shape. Implemented as a compare-and-toggle against
the engine's existing flight-mode field in `app.js`'s setter rather than
adding a new `observationEngine.setWarpFlight()` method (the report's
suggested name doesn't exist in `observation-engine.js`), keeping that file's
change to the two dock-routing lines only.
Both targets are covered end-to-end (registration, snapshot inclusion, mutation
routing, cross-surface convergence) by tests in `scifi-surfaces.test.js`.
## G. Observation audience behavior
`observationAudienceActive()` = `observationActive || surfaceOwner.attachedCount() > 0`.
The ambient-activity scheduler and `triggerObservationActivity` now gate on this
predicate instead of `observationActive` alone, so a presentation-only audience
(console in normal mode, one or more Observation panes attached) keeps
procedural activity running. `observationActive` itself is unchanged and still
gates the console's own local overlay rendering. Activity *decisions* are made
once by the authority and broadcast; each surface renders its own visual
realization of "an event fired now" rather than a byte-identical copy (see
Deviations, below) — narrative timing is shared, exact pixel content is not.
## H. Standalone verification
Not run live in this session (see §K). By construction: `index.html` with no
query string resolves to console mode via `js/surface-mode.js`'s `resolve()`,
which is unchanged from today's single-mode behavior for that input, and no
code path in the console branch was removed — only guarded with `isConsoleMode`,
which is `true` for that input. This is asserted structurally by
`scifi-surfaces.test.js`'s mode-resolution tests, but not confirmed by opening
a real browser.
## I. Fixture synchronization
`test-fixtures/reference-exhibits/scifi/` was resynced by copying the seven
changed/new files verbatim from `G:\.vibe\SciFi-XZBT` after the source was
syntax-checked and its own focused tests passed. `test-fixtures/PROVENANCE.md`
records the resync. No fixture-local corrections were introduced — drift
prevention relies on this being a discrete, documented, one-directional copy
step (source → fixture), matching the procedure Step 5 used.
## J. Tests
| Suite | Result |
| --- | --- |
| `tests/scifi-surfaces.test.js` (new) | 24/24 pass |
| `tests/local-surfaces.test.js` | 20/20 pass |
| Full NGN suite (`npm test`) | 149/154 pass |
The 5 failures are all in `tests/postmessage-interop.test.js` (`hello timed
out` ×4, one regex check), and are **not** caused by this step's changes.
Root cause: `contract-adapter.js`'s `_setupWindowBridge` gates inbound
messages on exact string equality against `xzbt: '5.2'`, while NGN's
`src/host.js` — modified independently, before this session, and outside this
step's scope — now stamps outgoing envelopes `xzbt: '5.3'`. Fixing this
requires editing the handshake/envelope gating logic that the 6.7A report
explicitly marks "must not change" for Step 6.7B. This is flagged as a
pre-existing cross-repo integration gap for separate resolution, not silently
worked around.
## K. Live verification
**Not performed in this session.** The implementation was delegated to and
completed by an agent operating via local shell access on the SciFi-XZBT and
XZBT-NGN working trees; it did not drive a real browser against a running
`npm start` server. The following report §15 acceptance-procedure steps
remain to be run manually:
1. Connect NGN to `http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html`; confirm `connected · synchronized`, Contract 5/3, target catalog.
2. Confirm the Surfaces panel shows exactly two entries — `surface.console` (Primary, no Open button) and `surface.observation` (`?surface=observation&xi=…`).
3. Open the Observation pane; confirm it renders the current universe/preset within ~1s, shows no RETURN ✕ / return pill, and background click does nothing.
4. Confirm the console pane does **not** black out — `view.observation` stays `false`.
5. From NGN, `set` `universe.selected`, `preset.selected`, `alert.active`, `view.viewport-frame`, `view.activity`, `view.pillars`, `view.warp-flight` and confirm both console and pane converge, with `stateRevision` advancing.
6. Reload the pane; confirm it returns to current (not default) state. Close/reopen; confirm the participant count does not ratchet.
7. Press WATCH EXPERIENCE on the console; confirm the pane is unaffected and `view.observation` toggles only the console overlay.
8. DevTools checks on the pane's frame: no WebLLM/Kokoro network activity, `window.generativeExperience === undefined`, no `AudioContext`.
9. Confirm transient activity events and AI announcements appear on console and pane at the same moment.
10. Disconnect/reconnect NGN; confirm clean pane release and rediscovery.
11. Standalone: open `index.html` directly (no NGN) — full console, WATCH EXPERIENCE, hotkeys, audio.
12. Standalone two-window: open `index.html?surface=observation&xi=<real id>` in a second window with no NGN present; confirm it attaches and mirrors.
13. Open `?surface=observation&xi=nonexistent` alone; confirm a waiting state, no invented authority.
14. Run `tools/package.ps1` (requires PowerShell — unavailable in this session's Linux shell) and open the packaged `dist/` file offline; confirm the two new scripts were inlined (report risk R7 — inlining failure is silent) and standalone behavior is unchanged.
## L. Deviations from the 6.7A report
- **Warp-flight setter name.** The report's suggested `observationEngine.setWarpFlight(...)` does not exist; implemented as a compare-and-toggle in `app.js`'s setter instead, touching only the two dock-routing lines in `observation-engine.js`.
- **No single `bootConsole()` function.** The guarded constructions/listeners remain in their existing positions in `app.js` (each wrapped in an `isConsoleMode` check) rather than being physically lifted into one named function, to respect `agents.md`'s "surgical, scoped edits" rule on a ~4,900-line file. Source-structure tests instead assert `isConsoleMode` appears immediately before each construction site. Functionally equivalent to the report's suggestion; less textually tidy.
- **Activity-event visuals are decision-synchronized, not pixel-identical.** Byte-identical rendering across documents would require refactoring the ten universe generator functions to take explicit random parameters instead of calling `Math.random()` internally — judged out of proportion to this step. The *decision* to fire an event, and its narrative timing, is shared; each surface renders its own visual instance of it. Documented as an extension of the already-accepted instrumentation-digit divergence (report §6).
## M. Remaining limitations
- **Audio-reactive waveform is flat on the Observation surface**, as explicitly scoped out of 6.7B. No audio graph exists there; `observation-engine.js`'s pre-existing `am.analyser`-null guards degrade it gracefully. Deferred, not solved, per the governing report.
- **`postmessage-interop.test.js`'s 5 failures** (see §J) are a pre-existing `5.2`/`5.3` handshake-gating mismatch between this SciFi-XZBT change and an independent, prior `src/host.js` change, outside this step's edit scope. Needs separate resolution — likely a version-gating fix in `contract-adapter.js`'s `_setupWindowBridge`, but that edit was withheld here because the report marks it must-not-change for 6.7B.
- **Live browser verification and the packaged single-file build were not exercised** in this session (§K) — recommended before treating 6.7B as fully accepted.
## Step 6.7 verdict
Step 6.7B implementation is **substantively complete against the 6.7A
architecture**: correct surface catalog, single authority, session-independent
local synchronization, both new canonical targets, all heavy-subsystem guards,
audience-activity decoupling, and standalone-preserving structure, all
supported by 44 passing focused/regression tests. Step 6.7 as a whole should
**not yet be marked complete**: the pre-existing handshake mismatch surfaced by
the full suite run needs a decision (fix now vs. track separately), and the
live NGN/browser and standalone/packaged-build verification in report §15
has not been performed. Recommend resolving the handshake mismatch and running
live verification before closing Step 6.7B, and explicitly deferring Step 6.8
until then, per this task's instructions.
+1 -1
View File
@@ -17,7 +17,7 @@
<div id="catalog">Connect an exhibit to discover its controls.</div>
</section>
<section id="surfaces-section"><h2>Presentation surfaces <span id="surface-count"></span></h2>
<p>Presentation surfaces are optional Contract 5.3 views advertised by the connected exhibit. Discovery is descriptor-driven; opening surfaces is a later phase.</p>
<p>Open Contract 5.3 presentation surfaces locally below. URLs resolve against the supplying exhibit. Disconnecting or switching exhibits closes these views. Remote/cast displays remain deferred.</p>
<div id="surfaces"><p>No presentation surfaces advertised.</p></div>
</section>
<section><h2>State and metadata</h2><button id="refresh" disabled>Refresh state</button>
+8
View File
@@ -26,6 +26,14 @@ export class ExhibitConnection {
const frame = this.createFrame(); this.frame = frame;
frame.addEventListener('load', () => {
if (generation !== this.generation) return;
try {
// Redirected entry points establish the session at their final document.
if (frame.contentWindow) this.url = exhibitURL(frame.contentWindow.location.href, this.base);
} catch {
this.disconnect();
this.host.report(new ProtocolError('INVALID_LOCATION', 'Exhibit navigation left the permitted origin.'));
return;
}
clearTimeout(this.timer); this.loading = false; this.attach();
});
this.timer = setTimeout(() => {
+101
View File
@@ -0,0 +1,101 @@
import { resolveSurfaceURL } from './surface-url.js';
import { validateSurfaceCatalog } from './validation.js';
// A resource check catches HTTP errors (iframes fire load even for a 404).
// Redirects are refused before navigation; the local server needs none.
export async function checkSurfaceResource(url, signal) {
const response = await fetch(url, { method: 'HEAD', redirect: 'error', signal });
if (!response.ok) throw new Error(`Surface resource returned HTTP ${response.status}.`);
}
// Presentation frames have no host transport and never establish host sessions.
export class LocalSurfaces {
constructor({ host, createFrame, changed = () => {}, checkResource = checkSurfaceResource, loadTimeoutMs = 15000 }) {
Object.assign(this, { host, createFrame, changed, checkResource, loadTimeoutMs });
this.entries = new Map();
this.unsubscribe = host.subscribe(() => { this.reconcile(); this.changed(); });
this.reconcile();
}
reconcile() {
const base = this.host.exhibitBaseUrl;
const catalog = this.host.status === 'connected' && base ? this.host.surfaces : [];
if (catalog === this.catalog && base === this.base) return;
const valid = validateSurfaceCatalog({ surfaces: catalog }, base);
for (const [id, entry] of this.entries) {
const descriptor = valid.find(s => s.id === id);
if (base !== this.base || !descriptor || descriptor.url !== entry.descriptor.url
|| descriptor.primary !== entry.descriptor.primary) {
this.release(entry); this.entries.delete(id);
}
}
this.catalog = catalog; this.base = base;
for (const descriptor of valid) {
const url = resolveSurfaceURL(descriptor.url, base);
// Full URL equality matters: queries and fragments can designate other views.
const control = descriptor.primary && url === new URL(base).href;
const entry = this.entries.get(descriptor.id) ?? { url, control, state: control ? 'control' : 'closed', frame: null, error: '' };
entry.descriptor = descriptor;
this.entries.set(descriptor.id, entry);
}
}
release(entry) {
entry.attempt = null; clearTimeout(entry.timer); entry.abort?.abort();
// Deterministic, exhibit-agnostic pre-removal lifecycle notice: NGN is
// the only side that knows *for certain* a frame is about to be removed
// or replaced, so it gives the frame one last synchronous chance to
// clean itself up before that happens. This is a direct same-origin
// call, not a postMessage, so it can't be dropped by the frame's
// context going away before a queued message is delivered. A frame
// that defines no such hook (or throws) is unaffected -- this carries
// no exhibit-state knowledge and nothing depends on it running.
try { entry.frame?.contentWindow?.__xzbtSurfaceDispose?.(); } catch { /* best-effort */ }
entry.frame?.remove(); entry.frame = null;
}
close(id) {
const entry = this.entries.get(id);
if (!entry || entry.control) return;
this.release(entry); entry.state = 'closed'; entry.error = ''; this.changed();
}
async open(id, reload = false) {
const entry = this.entries.get(id);
if (!entry || entry.control || (!reload && ['loading', 'open'].includes(entry.state))) return;
this.release(entry);
const attempt = {}; entry.attempt = attempt;
entry.abort = new AbortController(); entry.state = 'loading'; entry.error = '';
const current = () => entry.attempt === attempt && this.entries.get(id) === entry;
const fail = error => {
if (!current()) return;
this.release(entry); entry.state = 'error'; entry.error = error.message; this.changed();
};
entry.timer = setTimeout(() => fail(new Error('Surface load timed out. Check the URL and reload.')), this.loadTimeoutMs);
this.changed();
try {
await this.checkResource(entry.url, entry.abort.signal);
if (!current()) return;
const frame = this.createFrame(entry); entry.frame = frame;
frame.addEventListener('load', () => {
if (!current()) return;
try {
if (new URL(frame.contentWindow.location.href).origin !== new URL(this.base).origin) {
throw new Error('Surface navigated outside the exhibit origin.');
}
clearTimeout(entry.timer); entry.state = 'open'; this.changed();
} catch (error) { fail(error); }
});
frame.addEventListener('error', () => fail(new Error('Surface frame could not load.')));
frame.src = entry.url;
} catch (error) { fail(error); }
}
reload(id) { return this.open(id, true); }
// Called by the UI's container observer if a pane is removed externally.
sweep() {
for (const entry of this.entries.values()) {
if (entry.frame && !entry.frame.isConnected) this.close(entry.descriptor.id);
}
}
dispose() {
this.unsubscribe();
for (const entry of this.entries.values()) this.release(entry);
this.entries.clear();
}
}
+14
View File
@@ -0,0 +1,14 @@
// Contract 5.3 §31.4. Discovery and rendering share this resolver.
export function resolveSurfaceURL(value, exhibitBaseUrl) {
if (typeof value !== 'string' || !value.trim() || value !== value.trim() || /[\u0000-\u001f\u007f]/.test(value)
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value) || value.startsWith('//')) {
throw new Error('Surface URL must be a same-origin-relative reference (§31.4).');
}
const base = new URL(exhibitBaseUrl);
const resolved = new URL(value, base);
if (!['http:', 'https:'].includes(base.protocol) || resolved.origin !== base.origin
|| resolved.username || resolved.password) {
throw new Error('Surface URL must resolve same-origin with the supplying HTTP exhibit (§31.4).');
}
return resolved.href;
}
+53 -8
View File
@@ -2,6 +2,7 @@ import { ExhibitHost } from './host.js';
import { argumentSchema } from './validation.js';
import { postMessageTransport } from './transport/post-message.js';
import { ExhibitConnection } from './connection.js';
import { LocalSurfaces } from './local-surfaces.js';
const host = new ExhibitHost();
const byId = id => document.getElementById(id);
@@ -9,6 +10,7 @@ const json = value => JSON.stringify(value, null, 2);
const rows = new Map();
let renderedCatalog = null;
let renderedSurfaces = null;
const surfaceRows = new Map();
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
createFrame() {
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
@@ -17,6 +19,16 @@ const connection = new ExhibitConnection({ host, base: location.href, transport:
return frame;
}
});
const localSurfaces = new LocalSurfaces({ host, changed: render, createFrame(entry) {
const frame = document.createElement('iframe'); frame.title = entry.descriptor.label;
// Like the control frame, append only after assigning src.
queueMicrotask(() => {
if (entry.frame === frame) surfaceRows.get(entry.descriptor.id)?.pane.append(frame);
});
return frame;
} });
new MutationObserver(() => localSurfaces.sweep()).observe(byId('surfaces'), { childList: true, subtree: true });
window.addEventListener('pagehide', () => connection.disconnect());
function element(tag, text, parent) {
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
parent?.append(node); return node;
@@ -102,18 +114,39 @@ function buildCatalog() {
}
}
function buildSurfaces() {
const container = byId('surfaces'); container.replaceChildren();
// Preserve live panes across registry refreshes; replacing their ancestors
// would unload the documents even if their descriptor URLs did not change.
const container = byId('surfaces');
for (const [id, row] of surfaceRows) if (!localSurfaces.entries.has(id)) {
row.card.remove(); surfaceRows.delete(id);
}
container.querySelectorAll(':scope > p').forEach(node => node.remove());
byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : '';
if (!host.surfaces.length) {
element('p', host.sessionId ? 'No presentation surfaces advertised.' : 'No presentation surfaces advertised.', container);
element('p', 'No presentation surfaces advertised.', container);
return;
}
for (const surface of host.surfaces) {
const card = element('article', undefined, container); card.className = 'surface';
element('h3', surface.label, card);
element('code', surface.id, card);
const badges = element('div', undefined, card);
if (surface.primary) { const b = element('span', 'Primary', badges); b.className = 'surface-badge primary'; }
let row = surfaceRows.get(surface.id);
if (!row) {
const card = element('article', undefined, container); card.className = 'surface';
const metadata = element('div', undefined, card);
const status = element('p', '', card); status.setAttribute('role', 'status');
const controls = element('div', undefined, card); controls.className = 'controls';
const buttons = {};
for (const [label, method] of [['Open', 'open'], ['Reload', 'reload'], ['Close', 'close']]) {
const button = element('button', label, controls); buttons[method] = button;
button.addEventListener('click', () => localSurfaces[method](surface.id));
}
const pane = element('div', undefined, card);
row = { card, metadata, status, buttons, pane };
surfaceRows.set(surface.id, row);
}
row.metadata.replaceChildren();
element('h3', surface.label, row.metadata);
element('code', surface.id, row.metadata);
const badges = element('div', undefined, row.metadata);
const b = element('span', surface.primary ? 'Primary' : 'Non-primary', badges); b.className = 'surface-badge';
if (surface.role) { const b = element('span', `Role: ${surface.role}`, badges); b.className = 'surface-badge'; }
if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; }
const meta = [];
@@ -121,7 +154,7 @@ function buildSurfaces() {
if (surface.url) meta.push(`URL: ${surface.url}`);
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`);
if (meta.length) { const p = element('p', meta.join(' · '), card); p.className = 'surface-meta'; }
if (meta.length) { const p = element('p', meta.join(' · '), row.metadata); p.className = 'surface-meta'; }
}
}
function render() {
@@ -148,6 +181,18 @@ function render() {
byId('disconnect').disabled = !connection.frame;
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); }
for (const [id, row] of surfaceRows) {
const entry = localSurfaces.entries.get(id);
const state = entry?.state ?? 'unavailable';
const requirements = (Array.isArray(entry?.descriptor.requires) ? entry.descriptor.requires : [])
.filter(id => host.capabilities.find(c => c.id === id)?.state !== 'ready');
row.status.textContent = state === 'control'
? 'Open in the authoritative control frame. Use Connection controls to reload or disconnect.'
: `${state}${entry?.error ? ': ' + entry.error : ''}${requirements.length ? ' · Unavailable: ' + requirements.join(', ') : ''}`;
row.buttons.open.disabled = !entry || ['control', 'open', 'loading'].includes(state) || requirements.length > 0;
row.buttons.reload.disabled = !entry || ['control', 'closed'].includes(state);
row.buttons.close.disabled = !entry || ['control', 'closed'].includes(state);
}
for (const row of rows.values()) {
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
+11 -26
View File
@@ -1,3 +1,5 @@
import { resolveSurfaceURL } from './surface-url.js';
export class ProtocolError extends Error {
constructor(code, message) { super(message); this.name = 'ProtocolError'; this.code = code; }
}
@@ -80,28 +82,6 @@ export function validateReportedValue(target, value) {
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
}
}
// Contract 5.3 §31.4 — url must be relative/query/fragment, never absolute or protocol-relative.
// Returns false if the structural form is invalid (scheme present or protocol-relative).
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;
}
// Contract 5.3 §31.4 — resolve url against exhibitBaseUrl and confirm same-origin.
// Returns a diagnostic string if cross-origin, or null when safe.
function crossOriginReason(url, exhibitBaseUrl) {
try {
const resolved = new URL(url, exhibitBaseUrl);
const base = new URL(exhibitBaseUrl);
if (resolved.origin !== base.origin) return `resolved URL "${resolved.href}" is not same-origin with exhibit base "${base.origin}" (§31.4)`;
} catch {
return `could not resolve url "${url}" against exhibit base "${exhibitBaseUrl}"`;
}
return null;
}
// Contract 5.3 §31.2 individual descriptor validation. Returns a diagnostic string or null.
// exhibitBaseUrl is optional; when present, §31.4 same-origin resolution is applied.
function invalidSurfaceEntryReason(d, seenIds, exhibitBaseUrl) {
@@ -112,11 +92,16 @@ function invalidSurfaceEntryReason(d, seenIds, exhibitBaseUrl) {
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 (§31.4)';
if (exhibitBaseUrl) {
const reason = crossOriginReason(d.url, exhibitBaseUrl);
if (reason) return reason;
for (const field of ['description', 'role', 'aspectRatio', 'category']) {
if (d[field] !== undefined && typeof d[field] !== 'string') return `${field} must be a string`;
}
if (d.requires !== undefined && (!Array.isArray(d.requires) || d.requires.some(id => typeof id !== 'string'))) {
return 'requires must be an array of capability IDs';
}
// Legacy callers without a base get form validation against a synthetic origin.
// This fallback is never used for rendering: LocalSurfaces requires a real base.
try { resolveSurfaceURL(d.url, exhibitBaseUrl ?? 'http://surface-validation.invalid/'); }
catch (error) { return `url: ${error.message}`; }
return null;
}
+73
View File
@@ -29,3 +29,76 @@ Local compatibility corrections:
NGN has no fixture-specific target logic. These corrections apply only to
the local fixture copies. No upstream fixes or independent conformance
certification are claimed.
## Step 6.7B resync (2026-09-15)
The `scifi/` fixture was resynced from the real SciFi-XZBT exhibit source at
`G:/.vibe/SciFi-XZBT/` after that repo's Step 6.7B implementation (SciFi
Observation Surface Integration, `docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md`).
Copied files (byte-for-byte from source, no fixture-local corrections):
- `index.html` — two new `<script>` tags for `surface-mode.js`/`surface-bus.js`
- `js/app.js` — boot-mode resolution, console/observation branching, the
surface-attachment bus wiring, `view.pillars`/`view.warp-flight` routing
- `js/contract-adapter.js``surfaces`/`instanceId` constructor options,
the `view.pillars`/`view.warp-flight` targets, `onLocalChange`/`onLocalAction`
hooks, `describe()`'s conditional `surfaces` field, version bump to 5.3.0
- `js/observation-engine.js` — WARP/PILLARS dock buttons now route through
`onMutation` (matching the existing VIEWPORT/RED ALERT/PRESET pattern)
instead of mutating engine state directly
- `css/style.css` — additive `body.surface-observation` rules
- `js/surface-mode.js` (new) — boot-mode resolver + surface catalog
- `js/surface-bus.js` (new) — instance-scoped BroadcastChannel sync
No fixture-local corrections were added on top of this resync; the fixture
is a faithful mechanical copy of the source as it stood after Step 6.7B.
`js/audio.js`, `js/config.js`, `js/core-animations.js`, `js/visualizer.js`,
`js/observation-bezels.js`, `js/generative-experience.js`, and `js/control-bus.js`
were not touched by Step 6.7B and were not recopied. `dist/` was not
regenerated (no PowerShell available in the environment that performed this
resync); it still reflects a pre-6.7B build and should be rebuilt from
source before any test or procedure that opens the packaged file.
## SciFi adapter handshake correction (2026-09-15)
Correction to the record above: the Step 6.7B resync note listed a "version
bump to 5.3.0" for `js/contract-adapter.js`, but no such bump was present in
the file as resynced — it still read `version = '5.2.0'` / `contractMinor = 2`.
The adapter also still carried a pre-5.3 inbound gate in `_setupWindowBridge`:
```js
if (!e.data || typeof e.data !== 'object' || e.data.xzbt !== '5.2') return;
```
That is an exact-string comparison against the advisory `xzbt` field, which
Contract 5.3 §6.5 designates as diagnostics-only metadata ("The negotiated
session contract major/minor is authoritative after handshake"). Because
XZBT-NGN's host stamps outgoing envelopes `xzbt: '5.3'` (Step 6.3), the gate
silently dropped every host message — including `hello` — and NGN's handshake
timed out against this exhibit (`tests/postmessage-interop.test.js`, four
tests).
Two source-side corrections were made in `G:/.vibe/SciFi-XZBT/js/contract-adapter.js`
and then copied here verbatim, keeping the one-directional source -> fixture
flow this file documents:
1. The inbound filter now checks the advisory field's *presence and type*
(`typeof e.data.xzbt !== 'string'`), matching how XZBT-NGN's host validates
the same field. It no longer branches on any particular value, so the
field cannot act as a compatibility gate for any sender.
2. The adapter advertises the contract it actually implements: `contractMinor`
`2` -> `3` and the exhibit product version `'5.2.0'` -> `'5.3.0'` (its
`describe()` already emitted the Contract 5.3 `surfaces` field added by
Step 6.7B). The file header now reads "Specification v5.3" to match.
The outbound advisory `xzbt: '5.2'` stamps on this adapter's own envelopes were
deliberately left unchanged: §6.5 makes the value non-authoritative, Step 6.7B
documented the envelope as unchanged, and no receiver — NGN included — reads
that value for admission. `dist/` remains a stale generated artifact and was
not regenerated.
Resynced file (source and fixture byte-identical,
SHA-256 `10ED30527153D5B30F23557BA6EC427884D470AB6561AC33C47E7F17AFA28DD9`):
- `js/contract-adapter.js`
@@ -0,0 +1,172 @@
- banner:
- heading "XZBT-NGN Exhibit Engine" [level=1]
- paragraph: Contract 5.2/5.3 · One exhibit at a time
- main:
- heading "Connection" [level=2]
- generic: Exhibit URL (same origin)
- textbox "Exhibit URL (same origin)":
- /placeholder: /public/exhibits/example/index.html
- text: http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html
- button "Load exhibit"
- button "Reconnect"
- button "Disconnect"
- paragraph: Enter a trusted exhibit's local served path or a URL on this origin. Load exhibit also switches exhibits. Reconnect starts a new session, or reloads the last exhibit after disconnect.
- status: connected · synchronized
- paragraph: Museum Gallery · 0.1.0 · Contract 5.3
- paragraph: "Exhibit location: http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html"
- iframe:
- banner:
- heading "Museum Gallery — Control Room" [level=1]
- generic: Primary surface · Contract 5.3 · standalone-capable
- generic: host attached
- main:
- paragraph: The Orrery
- paragraph: "Lighting: 60%"
- paragraph: "Rotation: 0.4x"
- paragraph: "Labels: ON"
- paragraph: "Attached non-primary surfaces: 0"
- complementary:
- heading "Artifact" [level=2]
- generic: Selected artifact
- combobox "Selected artifact":
- option "The Orrery" [selected]
- option "Star Map"
- option "Meteorite Fragment"
- heading "Environment" [level=2]
- generic: Lighting level
- generic: 60%
- slider "Lighting level 60%"
- generic: Rotation speed
- generic: 0.4x
- slider "Rotation speed 0.4x"
- generic: Labels enabled
- button "Toggle" [pressed]
- heading "Actions" [level=2]
- button "Flash spotlight"
- contentinfo:
- generic: Control Room
- generic: Control Room standing by.
- heading "Exhibit controls (5)" [level=2]
- generic "Capabilities"
- paragraph: Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.
- heading "gallery" [level=3]
- article:
- heading "Selected Artifact" [level=3]
- code: artifact.selected
- paragraph: selection · readable · writable
- status: "Reported value: \"the-orrery\""
- generic "Target descriptor"
- paragraph
- text: New value
- combobox "New value":
- option "The Orrery" [selected]
- option "Star Map"
- option "Meteorite Fragment"
- button "Set"
- article:
- heading "Lighting Level" [level=3]
- code: lighting.level
- paragraph: range · readable · writable
- status: "Reported value: 0.6"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.6"
- button "Set"
- article:
- heading "Rotation Speed" [level=3]
- code: rotation.speed
- paragraph: range · readable · writable
- status: "Reported value: 0.4"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.4"
- button "Set"
- article:
- heading "Labels Enabled" [level=3]
- code: labels.enabled
- paragraph: state · readable · writable
- status: "Reported value: true"
- generic "Target descriptor"
- paragraph
- text: New value
- checkbox "New value" [checked]
- button "Set"
- heading "action" [level=3]
- article:
- heading "Spotlight Flash" [level=3]
- code: action.spotlight-flash
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- heading "Presentation surfaces (3)" [level=2]
- paragraph: Open Contract 5.3 presentation surfaces locally below. URLs resolve against the supplying exhibit. Disconnecting or switching exhibits closes these views. Remote/cast displays remain deferred.
- article:
- heading "Control Room" [level=3]
- code: surface.control
- generic: Primary
- generic: "Role: control"
- paragraph: "URL: control.html"
- status: Open in the authoritative control frame. Use Connection controls to reload or disconnect.
- button "Open" [disabled]
- button "Reload" [disabled]
- button "Close" [disabled]
- article:
- heading "Artifact Display" [level=3]
- code: surface.artifact
- generic: Non-primary
- generic: "Role: ambient"
- paragraph: "URL: artifact.html"
- status: open
- button "Open" [disabled]
- button "Reload"
- button "Close"
- iframe:
- banner:
- heading "Museum Gallery — Artifact Display" [level=1]
- generic: Non-primary surface · attaches to the Control Room
- generic: surface
- main:
- paragraph: The Orrery
- paragraph: "Lighting: 60%"
- paragraph: "Rotation: 0.4x"
- paragraph:
- button "Cycle artifact"
- paragraph: Attached to Control Room.
- contentinfo:
- generic: Artifact Display
- generic: Waiting for shared state.
- article:
- heading "Information Wall" [level=3]
- code: surface.info-wall
- generic: Non-primary
- generic: "Role: information"
- paragraph: "URL: info-wall.html"
- status: open
- button "Open" [disabled]
- button "Reload"
- button "Close"
- iframe:
- banner:
- heading "Museum Gallery — Information Wall" [level=1]
- generic: Non-primary surface · attaches to the Control Room
- generic: surface
- main:
- paragraph: The Orrery
- paragraph: "Labels: ON"
- paragraph:
- button "Toggle labels"
- paragraph: Attached to Control Room.
- contentinfo:
- generic: Information Wall
- generic: Waiting for shared state.
- heading "State and metadata" [level=2]
- button "Refresh state"
- generic "Session and exhibit metadata"
- generic "Current persistent state"
- heading "Protocol diagnostics" [level=2]
- generic "Incoming events"
- generic "Protocol and errors"
@@ -0,0 +1,846 @@
- banner:
- heading "XZBT-NGN Exhibit Engine" [level=1]
- paragraph: Contract 5.2/5.3 · One exhibit at a time
- main:
- heading "Connection" [level=2]
- generic: Exhibit URL (same origin)
- textbox "Exhibit URL (same origin)":
- /placeholder: /public/exhibits/example/index.html
- text: /test-fixtures/reference-exhibits/scifi/index.html
- button "Load exhibit" [active]
- button "Reconnect"
- button "Disconnect"
- paragraph: Enter a trusted exhibit's local served path or a URL on this origin. Load exhibit also switches exhibits. Reconnect starts a new session, or reloads the last exhibit after disconnect.
- status: connected · synchronized
- paragraph: SciFi-XZBT · 5.2.0 · Contract 5.3
- paragraph: "Exhibit location: http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html"
- iframe:
- banner:
- generic "Click to Change Sci-Fi Universe":
- generic: STARFLIGHT
- generic: ▼
- generic: "INTERPRISE-G: MAIN BRIDGE"
- button "MUTE"
- generic: 75%
- generic: STARFLIGHT SOUND MATRIX v2.9
- main:
- complementary:
- generic: STARSHIP PRESETS
- 'button "Interprise-G: Main Bridge STARFLIGHT COMMAND"':
- generic: "Interprise-G: Main Bridge"
- generic: STARFLIGHT COMMAND
- 'button "Interprise-G: Main Engineering STARFLIGHT COMMAND"':
- generic: "Interprise-G: Main Engineering"
- generic: STARFLIGHT COMMAND
- 'button "Interprise-G: Crew Quarters STARFLIGHT COMMAND"':
- generic: "Interprise-G: Crew Quarters"
- generic: STARFLIGHT COMMAND
- 'button "USS Wayfarer: Bridge STARFLIGHT COMMAND"':
- generic: "USS Wayfarer: Bridge"
- generic: STARFLIGHT COMMAND
- 'button "USS Wayfarer: Class-N Worp Core STARFLIGHT COMMAND"':
- generic: "USS Wayfarer: Class-N Worp Core"
- generic: STARFLIGHT COMMAND
- 'button "USS Defiance: Tactical Bridge STARFLIGHT COMMAND"':
- generic: "USS Defiance: Tactical Bridge"
- generic: STARFLIGHT COMMAND
- 'button "Deep Space 8: Ops Center STARFLIGHT COMMAND"':
- generic: "Deep Space 8: Ops Center"
- generic: STARFLIGHT COMMAND
- 'button "Interprise NCC-1701.5: Bridge (TOS) STARFLIGHT COMMAND"':
- generic: "Interprise NCC-1701.5: Bridge (TOS)"
- generic: STARFLIGHT COMMAND
- 'button "Interprise NCC-1701.5: Engineering (TOS) STARFLIGHT COMMAND"':
- generic: "Interprise NCC-1701.5: Engineering (TOS)"
- generic: STARFLIGHT COMMAND
- 'button "Interprise NS-01: Command Bridge STARFLIGHT COMMAND"':
- generic: "Interprise NS-01: Command Bridge"
- generic: STARFLIGHT COMMAND
- generic: "INTERPRISE-G: MAIN BRIDGE"
- generic: Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARD computer chirps.
- generic: AUDIO ENGINE
- generic: STANDBY
- generic: CORE FREQ
- generic: 58.0 Hz
- button "▶ ENGAGE":
- generic: ▶
- generic: ENGAGE
- button "WARP JUMP"
- button "RED ALERT"
- button "YELLOW ALERT"
- button "WATCH EXPERIENCE"
- generic: Full-screen procedural visuals, soundscape, telemetry, events, and optional local AI announcements.
- 'generic "Toggle starship viewport window frame (ON: framed starship lounge / OFF: pure deep space)"':
- generic: VIEWPORT
- checkbox "Toggle Observation Viewport Frame" [checked]
- generic: "ON"
- 'generic "Toggle vertical structural window pillars / mullions (OFF: unobstructed panoramic view / ON: structural posts)"':
- generic: PILLARS
- checkbox "Toggle Window Pillars"
- generic: "OFF"
- generic "Controls how frequently and intensely Observation generates transient visual activity":
- generic: ACTIVITY
- slider "Observation activity level"
- generic: 60%
- generic "Keep OBSERVATION profile/status information visible. Turn off to fade it out over 30 seconds.":
- generic: HUD HOLD
- checkbox "Keep Observation information visible" [checked]
- generic: "ON"
- generic: MAIN MASTER VOLUME
- generic: 75%
- generic: "-2.5 dB"
- button "MUTE"
- button "25%"
- button "50%"
- button "75%"
- button "MAX"
- button ""
- slider "Main Volume"
- button "+"
- generic: FNC // FUTURISTIC NOICE CANCELLATION
- generic: ACTIVE
- button "FNC ENABLED" [pressed]
- generic: FNC LEVEL
- slider "Futuristic Noice Cancellation level"
- generic: 65%
- generic: "FNC comfort filter remains applied. Adaptive watchdog memory reset: preset change."
- generic: ACOUSTIC FREQUENCY SPECTRUM
- generic: PROCEDURAL HARMONIC ANALYSIS
- generic: "SPECTRUM BIN: 32 CH // REAL-TIME FFT"
- generic: PROCEDURAL SOUND ENGINE MIXER
- generic: LIVE PARAMETER MODULATION
- generic: 1. HULL DRONE
- generic: 65%
- generic: LEVEL
- slider
- generic: BASE TONE
- generic: 50 Hz
- slider
- generic: DAMPING CUTOFF
- generic: 105 Hz
- slider
- generic: 2. WORP CORE
- generic: 35%
- generic: LEVEL
- slider
- generic: PULSE RATE (BPM)
- generic: 48 BPM
- slider
- generic: REACTOR PITCH
- generic: 58 Hz
- slider
- generic: 3. LIFE SUPPORT
- generic: 55%
- generic: LEVEL
- slider
- generic: AIRFLOW AIR FILTER
- generic: 1600 Hz
- slider
- generic: 4. TELEMETRY
- generic: 45%
- generic: LEVEL
- slider
- generic: BACKGROUND CHIRP DENSITY
- generic: 65%
- slider
- complementary:
- generic: INTERMIX CASCADE
- generic: INTERMIX CHAMBER // ACTIVE
- generic: GENERATIVE EXPERIENCE
- generic: LOCAL WEBLLM + KOKORO TTS
- generic: LOCAL GENERATIVE SYSTEM
- generic: STANDBY
- text: LANGUAGE MODEL
- text: Qwen3-1.7B-q4f16_1-MLC
- text: Automatically prepared when the experience starts. No model selection required.
- text: WEBLLM
- generic: "OFF"
- text: KOKORO
- generic: "OFF"
- button "PREPARE AI"
- button "GENERATE NOW" [disabled]
- checkbox "AUTO UPDATES" [checked]
- text: AUTO UPDATES
- checkbox "SPEAK" [checked]
- text: SPEAK
- combobox "Kokoro voice":
- option "Kokoro voice loads on preparation" [selected]
- generic: VOICE AUTOMATION
- generic: 84%
- slider "Robotic voice effect intensity"
- generic: AUTO CADENCE
- generic: 90-180 SEC
- slider
- generic: AI and neural voice models load only when needed and are cached by the browser when supported.
- generic: GENERATIVE CHANNEL STANDBY
- generic: TV PRESENTATION
- generic: CAST-FRIENDLY 16:9
- checkbox "LOCK EXPERIENCE TO 16:9" [checked]
- text: LOCK EXPERIENCE TO 16:9
- generic: RENDER DENSITY
- generic: 1080P TARGET
- combobox:
- option "720P PERFORMANCE"
- option "1080P STANDARD" [selected]
- option "1440P HIGH"
- option "2160P ULTRA"
- generic: The scene keeps a canonical 16:9 layout on phones, PCs, and cast displays. Chrome still negotiates the final Cast stream resolution.
- generic: SLEEP TIMER
- generic: TIMER INACTIVE (CONTINUOUS)
- button "15M"
- button "30M"
- button "60M"
- button "OFF"
- generic: LCARD KEYPAD
- button "COM BADGE CHIRP"
- button "DOOR PNEUMATIC SWISH"
- button "BOSUN'S PIPE WHISTLE"
- button "MEDBAY ECG MONITOR"
- button "CARDASSIAN BULKHEAD DOOR"
- button "TURBOCHUTE WHOOSH"
- button "FABRICATOR SHIMMER"
- button "TRANSLOCATOR CYCLE"
- button "LCARD SINGLE CHIRP"
- contentinfo:
- generic: "SYSTEM STATUS: NORMAL"
- link "VIBE ENGINEERED BY LABYRICORN STUDIOS":
- /url: https://www.labyricorn.com/
- generic: "SPACE: PLAY/PAUSE | M: MUTE | ↑/↓: VOLUME"
- heading "Exhibit controls (61)" [level=2]
- generic "Capabilities"
- paragraph: Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.
- heading "audio" [level=3]
- article:
- heading "Master Volume" [level=3]
- code: mix.master
- paragraph: range · readable · writable
- status: "Reported value: 0.75"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.75"
- button "Set"
- article:
- heading "Hull Drone Level" [level=3]
- code: mix.hull.level
- paragraph: range · readable · writable
- status: "Reported value: 0.65"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.65"
- button "Set"
- article:
- heading "Hull Drone Frequency" [level=3]
- code: mix.hull.frequency
- paragraph: range · readable · writable
- status: "Reported value: 50"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "50"
- button "Set"
- article:
- heading "Hull Drone Cutoff" [level=3]
- code: mix.hull.cutoff
- paragraph: range · readable · writable
- status: "Reported value: 105"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "105"
- button "Set"
- article:
- heading "Drive / Core Level" [level=3]
- code: mix.drive.level
- paragraph: range · readable · writable
- status: "Reported value: 0.35"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.35"
- button "Set"
- article:
- heading "Drive Pulse Rate" [level=3]
- code: mix.drive.pulse-rate
- paragraph: range · readable · writable
- status: "Reported value: 48"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "48"
- button "Set"
- article:
- heading "Drive Carrier Frequency" [level=3]
- code: mix.drive.carrier
- paragraph: range · readable · writable
- status: "Reported value: 58"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "58"
- button "Set"
- article:
- heading "Environmental Airflow Level" [level=3]
- code: mix.environment.level
- paragraph: range · readable · writable
- status: "Reported value: 0.55"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.55"
- button "Set"
- article:
- heading "Environmental Filter Cutoff" [level=3]
- code: mix.environment.cutoff
- paragraph: range · readable · writable
- status: "Reported value: 1600"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "1600"
- button "Set"
- article:
- heading "Telemetry Level" [level=3]
- code: mix.telemetry.level
- paragraph: range · readable · writable
- status: "Reported value: 0.45"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.45"
- button "Set"
- article:
- heading "Telemetry Density" [level=3]
- code: mix.telemetry.density
- paragraph: range · readable · writable
- status: "Reported value: 0.65"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.65"
- button "Set"
- article:
- heading "Futuristic Noise Cancellation Level" [level=3]
- code: fnc.level
- paragraph: range · readable · writable
- status: "Reported value: 0.65"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.65"
- button "Set"
- article:
- heading "Futuristic Noise Cancellation Active" [level=3]
- code: fnc.enabled
- paragraph: state · readable · writable
- status: "Reported value: true"
- generic "Target descriptor"
- paragraph
- text: New value
- checkbox "New value" [checked]
- button "Set"
- heading "speech" [level=3]
- article:
- heading "Speech Robotic Modulation" [level=3]
- code: speech.robot-amount
- paragraph: range · readable · writable
- status: "Reported value: 0.84"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.84"
- button "Set"
- article:
- heading "Speak Text Line" [level=3]
- code: speech.say
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Generate Ambient AI Announcement" [level=3]
- code: speech.generate-announcement
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- heading "observation" [level=3]
- article:
- heading "Observation Visual Activity" [level=3]
- code: view.activity
- paragraph: range · readable · writable
- status: "Reported value: 0.6"
- generic "Target descriptor"
- paragraph
- text: New value
- spinbutton "New value": "0.6"
- button "Set"
- article:
- heading "Observation Viewport Active" [level=3]
- code: view.observation
- paragraph: state · readable · writable
- status: "Reported value: false"
- generic "Target descriptor"
- paragraph
- text: New value
- checkbox "New value"
- button "Set"
- article:
- heading "Starship Bezel Frame Framing" [level=3]
- code: view.viewport-frame
- paragraph: state · readable · writable
- status: "Reported value: true"
- generic "Target descriptor"
- paragraph
- text: New value
- checkbox "New value" [checked]
- button "Set"
- heading "transport" [level=3]
- article:
- heading "Audio Engine Online" [level=3]
- code: transport.playing
- paragraph: state · readable · writable
- status: "Reported value: false"
- generic "Target descriptor"
- paragraph
- text: New value
- checkbox "New value"
- button "Set"
- article:
- heading "Master Mute" [level=3]
- code: transport.muted
- paragraph: state · readable · writable
- status: "Reported value: false"
- generic "Target descriptor"
- paragraph
- text: New value
- checkbox "New value"
- button "Set"
- heading "alert" [level=3]
- article:
- heading "Active Tactical Alert Status" [level=3]
- code: alert.active
- paragraph: selection · readable · writable
- status: "Reported value: \"none\""
- generic "Target descriptor"
- paragraph
- text: New value
- combobox "New value":
- option "Condition Green (Normal)" [selected]
- option "Yellow Alert"
- option "Red Alert"
- button "Set"
- heading "universe" [level=3]
- article:
- heading "Active Sci-Fi Universe" [level=3]
- code: universe.selected
- paragraph: selection · readable · writable
- status: "Reported value: \"starfleet\""
- generic "Target descriptor"
- paragraph
- text: New value
- combobox "New value":
- option "Starfleet / Galaxy" [selected]
- option "Whataverse / Tardix"
- option "Industrial / Weyland"
- option "Bioships / Leviathan"
- option "Retro Future / 1960s"
- option "Colonial Military"
- option "Deep Space Generation"
- option "Frontier & Salvage"
- option "Orbital Stations & Docks"
- option "Whimsical Fleet"
- button "Set"
- heading "preset" [level=3]
- article:
- heading "Active Audio Preset" [level=3]
- code: preset.selected
- paragraph: selection · readable · writable
- status: "Reported value: \"tng-bridge\""
- generic "Target descriptor"
- paragraph
- text: New value
- combobox "New value":
- 'option "Interprise-G: Main Bridge" [selected]'
- 'option "Interprise-G: Main Engineering"'
- 'option "Interprise-G: Crew Quarters"'
- 'option "USS Wayfarer: Bridge"'
- 'option "USS Wayfarer: Class-N Worp Core"'
- 'option "USS Defiance: Tactical Bridge"'
- 'option "Deep Space 8: Ops Center"'
- 'option "Interprise NCC-1701.5: Bridge (TOS)"'
- 'option "Interprise NCC-1701.5: Engineering (TOS)"'
- 'option "Interprise NS-01: Command Bridge"'
- option "1962 Classic Console (1st / 2nd Doctors)"
- option "Edwardian Secondary Console (4th Doctor)"
- option "The Coral Living TARDIX (9th / 10th Doctors)"
- option "The Bronze Workshop (11th Doctor)"
- option "The Cool Machine (12th Doctor)"
- option "The Humming Crystal Console (13th Doctor)"
- option "The Endless White (14th / 15th Doctors)"
- 'option "USCSS Nostramo: Ore Refinery Bridge"'
- 'option "Serenety: Firewing-Class Cargo Hold"'
- 'option "Red Dwarve: Main Drive Corridor"'
- 'option "Starbug Z: Cockpit Environment"'
- 'option "The Rasa: Dark Matter Bridge"'
- 'option "Rocinanta: Combat Ops & Epsilon Drive"'
- 'option "Hawk Translocator: Command Module"'
- 'option "Valley Foundry: Agro-Dome Forest Hub"'
- 'option "Meya: Leviathon Central Nexus"'
- 'option "Talym: Gunship Neural Bridge"'
- 'option "The Lexxi: Primary Organ Bridge"'
- 'option "Vorlan Cruiser: Sentient Core"'
- 'option "Wraithe Hive Ship: Throne Chamber"'
- 'option "Species 8675309: Fluid Bioship"'
- 'option "Jupiter Duece: Upper Deck Astrogator"'
- 'option "The Liberation: Zen Flight Bridge"'
- 'option "USS Cygnis: Victorian Engine Hall"'
- 'option "USS Palomina: Deep Research Pod"'
- 'option "Discovery Zero: Habitation Centrifuge"'
- 'option "Dim Star: Bomb Bay & Quarters"'
- 'option "Gunstarr: Tactical Combat Cockpit"'
- 'option "The Seeker: 25th Century Flagship"'
- 'option "USS Solaco: Conestega Hangar Deck"'
- 'option "Battlestar Galactica: Combat Information Center (CIC)"'
- 'option "Pale Star: Minbara/Vorlan Hybrid Bridge"'
- 'option "EAS Agamemnon II: Omega Destroyer Bridge"'
- 'option "Andromeda Ascending: Command Deck"'
- 'option "Excaliber: Triumph-Class Heavy Combat Core"'
- 'option "Colonial Adder Mk II: Cockpit Atmosphere"'
- 'option "The Avalin: Interstellar Cruise Concourse"'
- 'option "The Nightflier: Telepathic Corridor"'
- 'option "USS Ascention: Generation Ship Promenade"'
- 'option "Ark Zero: Evacuation Ark Bridge"'
- 'option "Icarus I: Solar Shield Core"'
- 'option "Event Boundary: Gravity Singularity Core"'
- 'option "Lewis & Clarke: Rescue Cutter Bridge"'
- 'option "The Betsy: Salvage Freighter Mess"'
- 'option "BeBoppa: Living Quarters & Hangar"'
- 'option "Outlaw Sun: Grapple Bridge"'
- 'option "Scorpius: Wanderer Salvage Vessel"'
- 'option "The Raider: Havoc Shuttle Bridge"'
- 'option "The Milan: M-Ship Cockpit Lounge"'
- 'option "Babylon 4: Core Control & Zocolo"'
- 'option "Moonbase Beta: Main Mission Control"'
- 'option "Sebastopol Station: Habitation Deck"'
- 'option "Tyco Station: Asteroid Construction Bay"'
- 'option "Ceres Minor Station: Sub-Crustal Tunnels"'
- 'option "Gatehouse Station: Quarantine Transfer Deck"'
- 'option "USS Norval: Command Bridge"'
- 'option "NSEA Protractor: Beryllium Orb Engine Room"'
- 'option "Heart of Silver: Unlikelihood Bridge"'
- 'option "HMS Camden Key: Flight Deck & Mess"'
- 'option "Spaceorb One: Mega-Warship Bridge"'
- button "Set"
- heading "display" [level=3]
- article:
- heading "Display Ticker Message (Transient)" [level=3]
- code: display.ticker
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- heading "event" [level=3]
- article:
- heading "Red Alert Impulse" [level=3]
- code: event.alert-red
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Warp Jump Impulse" [level=3]
- code: event.warp-jump
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Demat Cycle Impulse" [level=3]
- code: event.demat
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Time Vortex Impulse" [level=3]
- code: event.vortex
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Cloister Bell Impulse" [level=3]
- code: event.cloister
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Flak Barrage Impulse" [level=3]
- code: event.flak-barrage
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "FTL Jump Impulse" [level=3]
- code: event.ftl-jump
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Singularity Engage Impulse" [level=3]
- code: event.singularity
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Solar Flare Roar Impulse" [level=3]
- code: event.solar-roar
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Afterburner Ignite Impulse" [level=3]
- code: event.afterburner
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Ludicrous Speed Impulse" [level=3]
- code: event.ludicrous-speed
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Improbability Drive Flip" [level=3]
- code: event.improbability-flip
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- heading "sfx" [level=3]
- article:
- heading "Comm Badge Chime" [level=3]
- code: sfx.comm-badge
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Pneumatic Door Swish" [level=3]
- code: sfx.door-swish
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Bosun Pipe Call" [level=3]
- code: sfx.bosun-whistle
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Medical Vital Monitor" [level=3]
- code: sfx.medical-monitor
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Heavy Bulkhead Door" [level=3]
- code: sfx.cardassian-door
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Turbochute Whoosh" [level=3]
- code: sfx.turbolift
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Fabricator Materialize" [level=3]
- code: sfx.replicator
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Translocator Beam" [level=3]
- code: sfx.transporter
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Console Single Chirp" [level=3]
- code: sfx.console-chirp
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Sonic Acoustic Screwdriver" [level=3]
- code: sfx.sonic
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Console Mechanical Lever Clunk" [level=3]
- code: sfx.lever-clunk
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Tardix Wood & Lock Door" [level=3]
- code: sfx.tardix-door
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Demat Switch Rotor" [level=3]
- code: sfx.demat-switch
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Telepathic Circuit Chime" [level=3]
- code: sfx.telepathic-chime
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Air Handler Valve Thud" [level=3]
- code: sfx.air-handler
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Mag-Boot Solenoid Latch" [level=3]
- code: sfx.mag-boot
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Comlock Electronic Tone" [level=3]
- code: sfx.comlock
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Solar Radiation Wind" [level=3]
- code: sfx.solar-roar
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Cassette Magnetic Transport Clunk" [level=3]
- code: sfx.tape-clunk
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Thruster Burner Pulse" [level=3]
- code: sfx.afterburner
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Radiation Geiger Counter" [level=3]
- code: sfx.geiger
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Air Valve & Dock Clamp" [level=3]
- code: sfx.dock-clamp
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "Dradis Combat Sonar Ping" [level=3]
- code: sfx.dradis-ping
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- article:
- heading "HAL Logic Melodic Chime" [level=3]
- code: sfx.hal-chime
- paragraph: impulse · not readable · not writable
- status: No persistent value reported.
- generic "Target descriptor"
- paragraph
- button "Invoke"
- heading "Presentation surfaces" [level=2]: Presentation surfaces
- paragraph: Open Contract 5.3 presentation surfaces locally below. URLs resolve against the supplying exhibit. Disconnecting or switching exhibits closes these views. Remote/cast displays remain deferred.
- paragraph: No presentation surfaces advertised.
- heading "State and metadata" [level=2]
- button "Refresh state"
- generic "Session and exhibit metadata"
- generic "Current persistent state"
- heading "Protocol diagnostics" [level=2]
- generic "Incoming events"
- generic "Protocol and errors"
@@ -0,0 +1,180 @@
Step 6.6 — Museum Gallery end-to-end validation through the NGN operator UI
Server: pre-existing `npm start` server (node server/serve.js) on http://127.0.0.1:4173,
started before this session and left running. A second instance started for
validation failed with EADDRINUSE and exited; the served state was unchanged.
NGN UI: http://127.0.0.1:4173/
Exhibit: http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html
Replacement exhibit: http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html
Method: live browser session against the served page; values read from the live DOM, the
exhibit frames, and NGN's own diagnostics panes. No test doubles.
--------------------------------------------------------------------
1. Initial connection (Load exhibit)
--------------------------------------------------------------------
status: connected · synchronized
identity: Museum Gallery · 0.1.0 · Contract 5.3
metadata: { sessionId: "sess-1f54f",
contract: { major: 5, minor: 3 },
exhibit: { product: "Museum Gallery", version: "0.1.0", build: "reference-exhibit" },
registryRevision: 1, stateRevision: 0, sequence: null }
capabilities: render: ready
targets (5): artifact.selected (selection, r/w), lighting.level (range, r/w),
rotation.speed (range, r/w), labels.enabled (state, r/w),
action.spotlight-flash (impulse, not readable, not writable)
state: { "artifact.selected": "the-orrery", "lighting.level": 0.6,
"rotation.speed": 0.4, "labels.enabled": true }
error log: one SESSION entry, no errors
--------------------------------------------------------------------
2. Surface discovery and primary handling
--------------------------------------------------------------------
Presentation surfaces (3):
surface.control Control Room Primary Role: control URL: control.html
surface.artifact Artifact Display Non-primary Role: ambient URL: artifact.html
surface.info-wall Information Wall Non-primary Role: information URL: info-wall.html
Primary card buttons: Open disabled, Reload disabled, Close disabled
Primary card status: "Open in the authoritative control frame. Use Connection controls to
reload or disconnect."
Non-primary panes rendered and each reported: "Attached to Control Room."
Control Room readout "Attached non-primary surfaces": 2
--------------------------------------------------------------------
3. Lifecycle (participant count in the authoritative Control Room)
--------------------------------------------------------------------
open both -> 2
reload -> 2
reload again -> 2
close one -> 1
reopen -> 2
Reload freshness probe: a marker written to the secondary frame contentWindow was absent
after reload (fresh document), and the document re-attached and re-rendered current state.
--------------------------------------------------------------------
4. Shared-state synchronization
--------------------------------------------------------------------
Control -> secondaries
Control Room combobox -> "star-map":
event { type: "selection.changed", sequence: 1, stateRevision: 1,
target: "artifact.selected", value: "star-map", source: "ui" }
Control Room "Star Map"; Artifact Display "Star Map"; Information Wall "Star Map"
Control Room lighting range -> 0.25:
event { type: "state.changed", sequence: 2, stateRevision: 2,
target: "lighting.level", value: 0.25, source: "ui" }
Control Room "25%"; Artifact Display "Lighting: 25%"
Secondary -> primary -> secondaries
Real mouse click on Artifact Display "Cycle artifact" (authoritative value "star-map"):
Control Room combobox became "meteorite"; Control Room label "Meteorite Fragment"
event { type: "selection.changed", sequence: 3, stateRevision: 3,
target: "artifact.selected", value: "meteorite", source: "ui" }
Information Wall -> "Meteorite Fragment"; Artifact Display re-rendered authoritative result
Sustained convergence (both secondaries open, alternating origins)
step 1 control select the-orrery -> all views "The Orrery" revision 7 count 2
step 2 info-wall toggle labels -> labels.enabled true revision 8 count 2
step 3 control rotation 0.6 -> rotation.speed 0.6 revision 9 count 2
step 4 artifact-display cycle -> artifact.selected star-map revision 10 count 2
Control Room, Artifact Display, Information Wall and NGN reported state all agreed
after every step.
Late join
close Information Wall -> count 1
mutate: rotation 1.7, labels OFF -> stateRevision 5
reopen Information Wall -> immediately rendered "Meteorite Fragment" / "Labels: OFF",
count back to 2
Reload recovery
mutate: lighting 0.9 -> stateRevision 6
reload Artifact Display -> fresh document returned with "Meteorite Fragment",
"Lighting: 90%", "Rotation: 1.7x" (current state, not defaults); count still 2
--------------------------------------------------------------------
5. NGN host commands
--------------------------------------------------------------------
Refresh state (state.get): succeeded; stateRevision 10, status connected · synchronized,
no error
set lighting.level = 0.35: succeeded; Control Room "35%" (range 0.35),
Artifact Display "Lighting: 35%";
event { type: "state.changed", sequence: 11, stateRevision: 11,
target: "lighting.level", value: 0.35,
source: "host", correlationId: "ngn-2-4" }
set artifact.selected = meteorite: succeeded; Control Room / Artifact Display /
Information Wall all "Meteorite Fragment";
event { type: "selection.changed", sequence: 12, stateRevision: 12,
target: "artifact.selected", value: "meteorite",
source: "host", correlationId: "ngn-2-6" }
invoke action.spotlight-flash: succeeded;
event { type: "action.executed", sequence: 13,
target: "action.spotlight-flash", args: {},
source: "host", correlationId: "ngn-2-8" };
exhibit gallery.spotlightFlashCount incremented;
Control Room and both secondaries announcement nodes flashed
"Spotlight flashed." (captured by a DOM mutation observer on the
Artifact Display announcement node: seen transitions
["Spotlight flashed.", "Waiting for shared state."])
--------------------------------------------------------------------
6. Reconnect and exhibit replacement
--------------------------------------------------------------------
Disconnect:
status: disconnected · not synchronized
catalog: "Connect an exhibit to discover its controls."
surfaces: "No presentation surfaces advertised."; surface cards 0; surface iframes 0
control frame removed; state {}; metadata all null; no error entries
Reconnect:
status: connected · synchronized; session sess-15cf1; contract { major: 5, minor: 3 }
targets (5); surfaces (3) with surface.control primary; participant count 0
no stale panes; both secondaries reopened -> "Attached to Control Room.", count 2
Switch to SciFi:
identity: SciFi-XZBT · 5.2.0 · Contract 5.3
metadata: sessionId "sess-2akzu3a7", contract { major: 5, minor: 3 },
registryRevision 1, stateRevision 0
status: connected · synchronized; targets (61); surfaces: none advertised
iframes referencing museum-gallery: 0
Switch back to Museum Gallery:
session sess-126c6; contract { major: 5, minor: 3 }; targets (5); surfaces (3) with
correct primary; participant count 0; iframes referencing scifi: 0
state: fresh exhibit defaults, stateRevision 0
--------------------------------------------------------------------
7. Failure isolation (live)
--------------------------------------------------------------------
Artifact Display document globals:
XZBTContractCore: undefined
MuseumGalleryContract: undefined
MuseumGalleryExhibit: undefined
XZBTHostTransport: undefined
MuseumGallerySurfaceBus: present
__museumGalleryDebug.core: absent
Forged protocol messages posted directly to the secondary window:
{ xzbt: "5.3", type: "hello", requestId: "probe-secondary", supportedContractMajors: [5] }
{ xzbt: "5.3", type: "set", requestId: "probe-set", sessionId: "x",
target: "artifact.selected", value: "meteorite" }
-> responses received: none; authoritative selection unchanged ("The Orrery")
Surface-local failure: Artifact Display frame navigated to
/test-fixtures/reference-exhibits/museum-gallery/does-not-exist.html (served "Not found")
-> NGN status stayed "connected · synchronized" on session sess-126c6;
control frame still control.html; registry/state unaffected; no error entry;
participant count 2 -> 0 with no stale entry
NGN per-surface Reload on the failed pane:
-> frame back at artifact.html, "Attached to Control Room.", participant count 1,
session still synchronized, no error entry
Automated-only isolation evidence (not re-injected live): malformed/invalid descriptor
catalogs, isolated HTTP/frame/reload failures, external frame removal detection, stalled
surface resource, redirect refusal on resource check, presentation-window impersonation
refusal, transport source/origin validation.
--------------------------------------------------------------------
8. Automated verification
--------------------------------------------------------------------
Focused: node --test tests/museum-gallery.test.js tests/local-surfaces.test.js
tests/surface-validation.test.js tests/postmessage-interop.test.js
tests/connection.test.js
tests 107 / pass 107 / fail 0
Full: node --test
tests 129 / pass 129 / fail 0
git diff --check -> clean (exit 0)
python devlog_editor.py --validate -> devlog-editor: devlog is valid (exit 0)
@@ -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');
+13
View File
@@ -50,6 +50,19 @@ test('stalled document load ends with recoverable error', async t => {
assert.equal(errors[0].code, 'LOAD_TIMEOUT');
connection.reconnect(); assert.equal(frames.length, 2);
});
test('redirected control document supplies the final same-origin base; foreign navigation is refused', t => {
const { connection, frames, connections, errors } = setup(t);
connection.load('/public/entry.html');
frames[0].contentWindow = { location: { href: base + 'public/gallery/control.html' } };
frames[0].handlers.load();
assert.equal(connection.url, base + 'public/gallery/control.html');
assert.equal(connections.length, 1);
frames[0].contentWindow.location.href = 'https://other.example/';
frames[0].handlers.load();
assert.equal(connection.frame, null); assert.equal(errors[0].code, 'INVALID_LOCATION');
assert.equal(connections.length, 1);
});
test('optional descriptor metadata is permissive when omitted and typed when supplied', () => {
const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [],
targets: [{ id: 'sample.enabled', kind: 'state', readable: true, writable: true, valueType: 'boolean', requires: [] }] };
+296
View File
@@ -0,0 +1,296 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { ExhibitHost } from '../src/host.js';
import { LocalSurfaces, checkSurfaceResource } from '../src/local-surfaces.js';
import { resolveSurfaceURL } from '../src/surface-url.js';
import { postMessageTransport } from '../src/transport/post-message.js';
import { createServer } from '../server/serve.js';
import { createServer as createHttpServer } from 'node:http';
const base = 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html';
const directory = new URL('.', base).href;
for (const [reference, expected] of [
['artifact.html', directory + 'artifact.html'], ['./artifact.html', directory + 'artifact.html'],
['../foo/bar.html', new URL('../foo/bar.html', base).href],
['?mode=wall', base + '?mode=wall'], ['#section', base + '#section'],
['room/../artifact.html', directory + 'artifact.html']
]) test(`surface URL resolves ${reference} against supplying exhibit`, () => assert.equal(resolveSurfaceURL(reference, base), expected));
test('surface URL rejects invalid, absolute, protocol-relative and cross-origin forms', () => {
for (const value of [null, {}, 1, '', ' ', 'https://evil.example/', base, '//evil.example/',
'\\\\evil.example/x', '/\\evil.example/x', ' javascript:alert(1)', 'java\nscript:alert(1)']) {
assert.throws(() => resolveSurfaceURL(value, base), undefined, String(value));
}
assert.throws(() => resolveSurfaceURL('view.html', 'data:text/html,test'));
});
function galleryCatalog() {
const context = vm.createContext({ window: {}, console, Date, Math, setTimeout, clearTimeout });
for (const file of ['shared/contract-core.js', 'museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js']) {
vm.runInContext(readFileSync(new URL('../test-fixtures/reference-exhibits/' + file, import.meta.url), 'utf8'), context);
}
const core = context.window.MuseumGalleryContract.create(new context.window.MuseumGalleryExhibit.Gallery());
return JSON.parse(JSON.stringify(core.describe().surfaces));
}
/** SciFi-XZBT's contract-adapter.js is self-contained (no shared
* contract-core.js dependency, unlike Museum Gallery); its own
* surface-mode.js supplies the real, production two-entry catalog. */
function sciFiCatalog() {
const context = vm.createContext({ window: {}, console, Date, Math, setTimeout, clearTimeout, URLSearchParams });
for (const file of ['scifi/js/surface-mode.js', 'scifi/js/contract-adapter.js']) {
vm.runInContext(readFileSync(new URL('../test-fixtures/reference-exhibits/' + file, import.meta.url), 'utf8'), context);
}
const surfaces = context.window.XZBTSurfaceMode.SURFACES('xi-test-instance');
const adapter = new context.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId: 'xi-test-instance', bindings: {} });
return JSON.parse(JSON.stringify(adapter.describe().surfaces));
}
function setup(t, options = {}) {
const host = new ExhibitHost();
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() });
const frames = [];
const manager = new LocalSurfaces({ host, checkResource: async () => {}, ...options,
createFrame(entry) {
const frame = new EventTarget();
frame.contentWindow = { location: { href: entry.url } };
frame.isConnected = true;
frame.remove = () => { frame.isConnected = false; };
frames.push(frame); return frame;
}
});
t.after(() => manager.dispose());
const secondary = host.surfaces.find(s => !s.primary).id;
return { host, manager, frames, secondary };
}
test('real Museum descriptors: primary reuses control; opening, duplicate open, reload, close and reopen', async t => {
const { host, manager, frames, secondary } = setup(t);
const primary = host.surfaces.find(s => s.primary).id;
assert.equal(manager.entries.get(primary).state, 'control');
await manager.open(primary); manager.close(primary); await manager.reload(primary);
assert.equal(frames.length, 0);
const opening = manager.open(secondary);
assert.equal(manager.entries.get(secondary).state, 'loading');
await manager.open(secondary); await opening;
assert.equal(frames.length, 1);
assert.equal(frames[0].src, resolveSurfaceURL(host.surfaces.find(s => s.id === secondary).url, base));
frames[0].dispatchEvent(new Event('load'));
assert.equal(manager.entries.get(secondary).state, 'open');
await manager.open(secondary); assert.equal(frames.length, 1);
await manager.reload(secondary); assert.equal(frames[0].isConnected, false);
frames[0].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'loading');
frames[1].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'open');
manager.close(secondary); assert.equal(manager.entries.get(secondary).state, 'closed');
assert.equal(frames[1].isConnected, false);
await manager.open(secondary); assert.equal(frames.length, 3);
assert.equal(host.sessionId, 'control-session'); assert.equal(host.sync, 'synchronized');
});
test('release() invokes a frame\'s __xzbtSurfaceDispose hook synchronously before removing it, on reload, close and disconnect', async t => {
// Deterministic lifecycle fix (Step 6.5B): local-surfaces.js does not know
// or care what an exhibit's own attach/detach protocol looks like -- it
// just gives a same-origin frame one last synchronous chance to clean
// itself up, via a well-known optional global, before the frame is
// removed. This proves the call happens, happens before removal, and
// that a frame with no such hook (or one that throws) is unaffected.
const host = new ExhibitHost();
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() });
const frames = [];
const disposeCalls = [];
const manager = new LocalSurfaces({
host, checkResource: async () => {},
createFrame(entry) {
const frame = new EventTarget();
const order = [];
frame.contentWindow = {
location: { href: entry.url },
__xzbtSurfaceDispose: () => { order.push('dispose'); disposeCalls.push(entry.url); }
};
frame.isConnected = true;
frame.remove = () => { order.push('remove'); frame.isConnected = false; frame._order = order; };
frames.push(frame); return frame;
}
});
t.after(() => manager.dispose());
const secondary = host.surfaces.find(s => !s.primary).id;
await manager.open(secondary);
frames[0].dispatchEvent(new Event('load'));
await manager.reload(secondary);
assert.deepEqual(frames[0]._order, ['dispose', 'remove'], 'dispose runs before remove on reload');
assert.equal(disposeCalls.length, 1);
frames[1].dispatchEvent(new Event('load'));
manager.close(secondary);
assert.deepEqual(frames[1]._order, ['dispose', 'remove'], 'dispose runs before remove on close');
assert.equal(disposeCalls.length, 2);
await manager.open(secondary);
frames[2].dispatchEvent(new Event('load'));
host.disconnect();
assert.deepEqual(frames[2]._order, ['dispose', 'remove'], 'dispose runs before remove on disconnect/exhibit-switch teardown');
assert.equal(disposeCalls.length, 3);
});
test('release() tolerates frames with no dispose hook, and a throwing hook does not block or corrupt teardown', async t => {
const host = new ExhibitHost();
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: base, surfaces: galleryCatalog() });
const frames = [];
let throwingHookCalls = 0;
const manager = new LocalSurfaces({
host, checkResource: async () => {},
createFrame(entry) {
const frame = new EventTarget();
// No __xzbtSurfaceDispose at all -- matches every non-Museum-Gallery
// reference exhibit local-surfaces.js also serves.
frame.contentWindow = { location: { href: entry.url } };
frame.isConnected = true;
frame.remove = () => { frame.isConnected = false; };
frames.push(frame); return frame;
}
});
t.after(() => manager.dispose());
const secondary = host.surfaces.find(s => !s.primary).id;
await manager.open(secondary);
frames[0].dispatchEvent(new Event('load'));
await manager.reload(secondary); // must not throw despite no hook present
assert.equal(frames[0].isConnected, false);
assert.equal(manager.entries.get(secondary).state, 'loading');
frames[1].contentWindow.__xzbtSurfaceDispose = () => { throwingHookCalls += 1; throw new Error('boom'); };
frames[1].dispatchEvent(new Event('load'));
manager.close(secondary); // a throwing hook must not prevent removal or the state transition
assert.equal(throwingHookCalls, 1);
assert.equal(frames[1].isConnected, false);
assert.equal(manager.entries.get(secondary).state, 'closed');
});
test('a separate primary page and query/fragment views are rendered from generic descriptors', async t => {
const { host, manager, frames } = setup(t);
for (const url of ['primary.html', '?mode=wall', '#primary']) {
host.surfaces = [{ id: 'other.main', label: 'Another primary', primary: true, kind: 'surface', url }]; host.changed();
assert.equal(manager.entries.get('other.main').state, 'closed');
await manager.open('other.main'); assert.equal(frames.at(-1).src, resolveSurfaceURL(url, base));
}
});
test('real SciFi-XZBT descriptors: the primary resolves to the already-open control pane and opens no second frame', async t => {
const sciFiBase = 'http://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html';
const host = new ExhibitHost();
Object.assign(host, { status: 'connected', sync: 'synchronized', sessionId: 'control-session', exhibitBaseUrl: sciFiBase, surfaces: sciFiCatalog() });
const frames = [];
const manager = new LocalSurfaces({
host, checkResource: async () => {},
createFrame(entry) {
const frame = new EventTarget();
frame.contentWindow = { location: { href: entry.url } };
frame.isConnected = true;
frame.remove = () => { frame.isConnected = false; };
frames.push(frame); return frame;
}
});
t.after(() => manager.dispose());
const primary = host.surfaces.find(s => s.primary).id;
const observation = host.surfaces.find(s => !s.primary).id;
assert.equal(primary, 'surface.console');
assert.equal(observation, 'surface.observation');
// The primary's url ('index.html') resolves to exactly exhibitBaseUrl, so
// it is recognized as the already-open control pane -- opening it must
// not create a second frame (report §4 "Why url: 'index.html' for the primary").
assert.equal(manager.entries.get(primary).state, 'control');
await manager.open(primary);
assert.equal(frames.length, 0, 'the primary must never be opened as a second frame');
// The observation surface is a bare query string against the same base
// and opens normally as any other secondary surface would.
await manager.open(observation);
assert.equal(frames.length, 1);
assert.equal(frames[0].src, resolveSurfaceURL(host.surfaces.find(s => s.id === observation).url, sciFiBase));
assert.ok(frames[0].src.includes('?surface=observation&xi='));
});
test('disconnect and exhibit switch release all frames and pending opens', async t => {
const { host, manager, frames, secondary } = setup(t);
await manager.open(secondary);
host.disconnect(); assert.equal(manager.entries.size, 0); assert.equal(frames[0].isConnected, false);
Object.assign(host, { status: 'connected', exhibitBaseUrl: base, surfaces: galleryCatalog() }); host.changed();
await manager.open(secondary);
host.exhibitBaseUrl = new URL('../another/control.html', base).href; host.changed();
assert.equal(frames[1].isConnected, false);
assert.equal(manager.entries.get(secondary).state, 'closed');
let finish;
manager.checkResource = () => new Promise(resolve => { finish = resolve; });
const pending = manager.open(secondary);
host.disconnect(); finish(); await pending;
assert.equal(frames.length, 2); assert.equal(manager.entries.size, 0);
});
test('rediscovery preserves unchanged frames and closes removed/changed descriptors', async t => {
const { host, manager, frames, secondary } = setup(t);
await manager.open(secondary);
host.surfaces = structuredClone(host.surfaces); host.changed();
assert.equal(manager.entries.get(secondary).frame, frames[0]);
host.surfaces = host.surfaces.map(s => s.id === secondary ? { ...s, url: 'other.html' } : s); host.changed();
assert.equal(frames[0].isConnected, false);
await manager.open(secondary);
host.surfaces = host.surfaces.filter(s => s.id !== secondary); host.changed();
assert.equal(frames[1].isConnected, false);
});
test('HTTP, frame and reload failures are isolated; external removal is detected', async t => {
const { host, manager, frames, secondary } = setup(t);
manager.checkResource = async () => { throw new Error('HTTP 404'); };
await manager.open(secondary); assert.equal(manager.entries.get(secondary).state, 'error');
assert.match(manager.entries.get(secondary).error, /404/);
manager.checkResource = async () => {};
await manager.reload(secondary); frames[0].dispatchEvent(new Event('error'));
assert.equal(manager.entries.get(secondary).state, 'error');
await manager.reload(secondary); frames[1].contentWindow.location.href = 'https://evil.example/';
frames[1].dispatchEvent(new Event('load')); assert.equal(manager.entries.get(secondary).state, 'error');
await manager.reload(secondary); frames[2].remove(); manager.sweep();
assert.equal(manager.entries.get(secondary).state, 'closed');
assert.equal(host.status, 'connected'); assert.equal(host.sync, 'synchronized');
});
test('stalled surface resource fails without harming the session', async t => {
const { host, manager, secondary } = setup(t, { loadTimeoutMs: 5, checkResource: () => new Promise(() => {}) });
void manager.open(secondary);
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(manager.entries.get(secondary).state, 'error');
assert.match(manager.entries.get(secondary).error, /timed out/);
assert.equal(host.status, 'connected');
});
test('malformed surfaces and invalid primary catalogs do not change authoritative session', t => {
const { host, manager } = setup(t);
for (const metadata of [{ requires: 'bad' }, { requires: [{}] }, { role: {} }, { description: [] }]) {
host.surfaces = [...galleryCatalog(), { id: 'bad.optional', label: 'Bad', kind: 'surface', primary: false, url: 'bad.html', ...metadata }]; host.changed();
assert.equal(manager.entries.size, 3);
}
host.surfaces = [...host.surfaces, { id: 'bad.view', url: '//evil.example/' }]; host.changed();
assert.equal(manager.entries.size, 3);
host.surfaces = host.surfaces.map(s => ({ ...s, primary: false })); host.changed();
assert.equal(manager.entries.size, 0); assert.equal(host.sessionId, 'control-session');
});
test('resource check refuses redirects before frame navigation', async t => {
let foreignRequests = 0;
const foreign = createHttpServer((_req, res) => { foreignRequests++; res.end('external'); });
await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
const server = createHttpServer((_req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${foreign.address().port}/` }); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => Promise.all([server, foreign].map(s => new Promise(resolve => s.close(resolve)))));
await assert.rejects(checkSurfaceResource(`http://127.0.0.1:${server.address().port}/`));
assert.equal(foreignRequests, 0);
});
test('presentation window cannot impersonate authoritative peer over production transport', () => {
const parent = new EventTarget(); parent.location = { href: base, origin: new URL(base).origin };
const control = {}, surface = {}, received = [];
const transport = postMessageTransport({ src: base, contentWindow: control }, parent);
transport.subscribe(message => received.push(message));
for (const source of [surface, control]) {
const event = new Event('message'); Object.assign(event, { source, origin: parent.location.origin, data: { type: 'state.changed' } }); parent.dispatchEvent(event);
}
assert.equal(received.length, 1); transport.close();
});
test('real static server resource check accepts Museum pages and rejects missing/outside mounts', async t => {
const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => server.close(resolve)));
const origin = `http://127.0.0.1:${server.address().port}`;
for (const descriptor of galleryCatalog()) await checkSurfaceResource(resolveSurfaceURL(descriptor.url, origin + new URL(base).pathname));
await assert.rejects(checkSurfaceResource(origin + '/test-fixtures/missing-surface.html'), /404/);
await assert.rejects(checkSurfaceResource(origin + '/README.md'), /404/);
});
+244 -3
View File
@@ -41,7 +41,7 @@ function makeContext() {
});
}
function makeOwner() {
function makeOwner(options) {
const ctx = makeContext();
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
for (const file of ['exhibit.js', 'contract-adapter.js', 'surface-bus.js']) {
@@ -49,7 +49,7 @@ function makeOwner() {
}
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
const core = ctx.window.MuseumGalleryContract.create(gallery);
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core);
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core, options);
return { ctx, gallery, core, bus };
}
@@ -162,7 +162,7 @@ test('Non-surface-aware exhibits are unaffected: describe() omits `surfaces` ent
const description = plain(core.describe());
assert.equal('surfaces' in description, false);
assert.equal(description.contract.major, 5);
assert.equal(description.contract.minor, 2, 'defaults are byte-identical to pre-5.3 behavior');
assert.equal(description.contract.minor, 3, 'shared contract-core.js now defaults every exhibit forward to Contract 5.3; surfaces stays opt-in regardless');
});
/* ------------------------------------------------------------------ *
@@ -362,6 +362,59 @@ test('Event sequence remains one stream regardless of which surface originated t
}
});
test('Attachment triggers connection callback without state mutation', async () => {
const counts = [];
let detached;
const detachNotification = new Promise((resolve) => { detached = resolve; });
const { core, bus } = makeOwner({
onConnectionChange(count) {
assert.equal(count, bus.attachedCount(), 'callback reports the current owner count');
counts.push(count);
if (count === 0) detached();
}
});
let surface;
try {
const before = plain(core.stateSnapshot());
const eventsBefore = plain(core.eventLog());
assert.equal(bus.attachedCount(), 0);
assert.deepEqual(counts, []);
surface = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 1);
assert.deepEqual(counts, [1]);
assert.deepEqual(plain(core.stateSnapshot()), before);
assert.deepEqual(plain(core.eventLog()), eventsBefore);
surface.link.detach();
surface = undefined;
let timer;
try {
await Promise.race([
detachNotification,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('detach callback timed out')), 500);
})
]);
} finally {
clearTimeout(timer);
}
assert.equal(bus.attachedCount(), 0);
assert.deepEqual(counts, [1, 0]);
// Irrelevant messages and an extra detach at zero are not count changes.
for (const data of [null, { type: 'unrelated' }, { type: 'detach' }]) {
bus.channel.onmessage({ data });
}
assert.equal(bus.attachedCount(), 0);
assert.deepEqual(counts, [1, 0]);
assert.deepEqual(plain(core.stateSnapshot()), before);
assert.deepEqual(plain(core.eventLog()), eventsBefore, 'no fake core events');
} finally {
closeAll(bus, surface);
}
});
test('Detach does not mutate state, and a later reattach still observes it correctly', async () => {
const { core, bus } = makeOwner();
let first, second;
@@ -421,3 +474,191 @@ test('A non-primary surface opened without the Control Room present times out an
assert.equal(ctx.window.MuseumGalleryContract, undefined);
closeAll({ link });
});
/* ------------------------------------------------------------------ *
* Participant lifecycle bookkeeping (Step 6.5B live defect regression)
*
* These simulate, at the surface-bus level, exactly what NGN's
* local-surfaces.js now does around a frame reload/close/reopen: it calls
* the frame's `__xzbtSurfaceDispose` hook (== the surface's own
* `link.detach()`) synchronously before removing the old iframe, and a
* reload/reopen produces a brand-new attach() call with its own fresh
* participant identity, exactly as a freshly-loaded document would.
* ------------------------------------------------------------------ */
/** Simulates NGN reloading/replacing one secondary's iframe: dispose the
* old attachment (as local-surfaces.js's release() does before removing
* the frame), then attach a fresh one (as the reloaded document does). */
async function simulateReload(oldSurface) {
oldSurface.link.detach();
return attachSurface(makeSurfaceContext());
}
test('Participant lifecycle: two secondaries attach to a count of exactly 2', async () => {
const { bus } = makeOwner();
let a, b;
try {
assert.equal(bus.attachedCount(), 0);
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
} finally {
closeAll(bus, a, b);
}
});
test('Participant lifecycle: reloading one secondary does not ratchet the count up', async () => {
const { bus } = makeOwner();
let a, b;
try {
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
a = await simulateReload(a);
assert.equal(bus.attachedCount(), 2, 'reload retires the old participant and adopts the new one -- net zero');
} finally {
closeAll(bus, a, b);
}
});
test('Participant lifecycle: repeated reloads leave the count stable', async () => {
const { bus } = makeOwner();
let a, b;
try {
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
for (let i = 0; i < 5; i += 1) {
a = await simulateReload(a);
assert.equal(bus.attachedCount(), 2, `count must remain 2 after reload #${i + 1}`);
}
} finally {
closeAll(bus, a, b);
}
});
test('Participant lifecycle: closing a secondary decrements deterministically without waiting for a Core mutation', async () => {
const { core, bus } = makeOwner();
let a, b;
try {
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
const revisionBeforeClose = core.stateRevision;
b.link.detach();
b = undefined;
await new Promise((r) => setTimeout(r, 20));
assert.equal(bus.attachedCount(), 1, 'close must decrement immediately, with no Core mutation involved');
assert.equal(core.stateRevision, revisionBeforeClose, 'closing a secondary never mutates exhibit state');
} finally {
closeAll(bus, a, b);
}
});
test('Participant lifecycle: reopening a closed secondary increments exactly once', async () => {
const { bus } = makeOwner();
let a, b;
try {
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
b.link.detach();
b = undefined;
await new Promise((r) => setTimeout(r, 20));
assert.equal(bus.attachedCount(), 1);
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2, 'reopen brings the count back to 2, not higher');
} finally {
closeAll(bus, a, b);
}
});
test('Participant lifecycle: repeated close/reopen never ratchets in either direction', async () => {
const { bus } = makeOwner();
let a, b;
try {
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
for (let i = 0; i < 3; i += 1) {
b.link.detach();
await new Promise((r) => setTimeout(r, 10));
assert.equal(bus.attachedCount(), 1, `count must be 1 after close #${i + 1}`);
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2, `count must be 2 after reopen #${i + 1}`);
}
} finally {
closeAll(bus, a, b);
}
});
test('Participant lifecycle: duplicate/late attach and detach messages cannot inflate or underflow the count', async () => {
const { bus } = makeOwner();
let a;
try {
a = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 1);
// A replayed/duplicated attach for a participant id that is already
// live (e.g. a retried message) must not inflate the count -- the
// owner tracks identities in a Set, so re-adding a live id is a no-op.
bus.channel.onmessage({ data: { type: 'attach', requestId: 'dup-req', participantId: 'known-participant' } });
bus.channel.onmessage({ data: { type: 'attach', requestId: 'dup-req', participantId: 'known-participant' } });
assert.equal(bus.attachedCount(), 2, 'two attach messages for the SAME id count as exactly one participant');
bus.channel.onmessage({ data: { type: 'detach', participantId: 'known-participant' } });
assert.equal(bus.attachedCount(), 1, 'detaching that id removes exactly the one participant it represents');
// A late/duplicate detach for an id that is no longer (or never was)
// present must not underflow the count.
bus.channel.onmessage({ data: { type: 'detach', participantId: 'known-participant' } });
bus.channel.onmessage({ data: { type: 'detach', participantId: 'not-a-real-participant' } });
assert.equal(bus.attachedCount(), 1, 'stale/unknown detach ids must not drive the count below the real count');
a.link.detach();
await new Promise((r) => setTimeout(r, 20));
assert.equal(bus.attachedCount(), 0);
bus.channel.onmessage({ data: { type: 'detach', participantId: 'not-a-real-participant' } });
assert.equal(bus.attachedCount(), 0, 'never goes negative or otherwise corrupts at zero');
} finally {
closeAll(bus, a);
}
});
test('Participant lifecycle: synchronization is unaffected by the lifecycle bookkeeping change', async () => {
const { core, bus } = makeOwner();
let a, b;
try {
a = await attachSurface(makeSurfaceContext());
b = await attachSurface(makeSurfaceContext());
assert.equal(bus.attachedCount(), 2);
// Control mutation still reaches both mirrors.
core.applyMutation('lighting.level', 0.42, 'ui');
await waitForEvent(a.events, (e) => e.target === 'lighting.level' && e.value === 0.42, 500);
await waitForEvent(b.events, (e) => e.target === 'lighting.level' && e.value === 0.42, 500);
// A secondary mutation request still reaches the primary and the other
// mirror through the canonical mutation path.
a.link.mutate('set', 'labels.enabled', false); // default is true, so false is an actual change
await waitForEvent(b.events, (e) => e.target === 'labels.enabled' && e.value === false, 500);
assert.equal(core.readValue('labels.enabled'), false);
// A reload (dispose + fresh attach) still gets a correct, current
// snapshot -- late-join/attach.snapshot behavior survives the change.
a = await simulateReload(a);
assert.equal(a.snapshot.values['lighting.level'], 0.42);
assert.equal(a.snapshot.values['labels.enabled'], false);
assert.equal(bus.attachedCount(), 2);
} finally {
closeAll(bus, a, b);
}
});
+247
View File
@@ -0,0 +1,247 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { ExhibitHost } from '../src/host.js';
import { postMessageTransport } from '../src/transport/post-message.js';
const SHARED = new URL('../test-fixtures/reference-exhibits/shared/', import.meta.url);
const ROOT = new URL('../test-fixtures/reference-exhibits/', import.meta.url);
const BASE_ORIGIN = 'http://127.0.0.1:4173';
function source(base, file) {
return readFileSync(new URL(file, base), 'utf8');
}
/**
* A REAL postMessage transport between two window-like objects -- not a
* synthetic in-memory peer that hand-crafts contract responses (see
* tests/host.test.js's peer() fixture, which fakes the wire protocol
* itself). This harness only supplies the generic browser plumbing --
* origin-checked delivery, async dispatch, and window.source identity,
* exactly per the postMessage spec -- and lets the REAL, unmodified
* production code on both ends (src/host.js + src/transport/post-message.js
* for NGN, and the exhibit-side code under test-fixtures/reference-exhibits/)
* do 100% of the actual protocol handling. This is the only way an
* exact-string envelope-gating bug living inside an exhibit's inbound
* message filter can ever be caught by an automated test -- a hand-rolled
* peer() fixture that answers `hello` directly can never exercise that gate
* at all. An optional `rewriteOutgoing` hook lets a test mutate every
* message NGN sends before it crosses the wire, without touching src/host.js
* itself -- used below to prove the advisory `xzbt` tag really is ignored.
*/
function makeWindowPair(exhibitPath, { rewriteOutgoing } = {}) {
const ngn = { location: { origin: BASE_ORIGIN, href: BASE_ORIGIN + '/' }, listeners: new Set() };
const exhibit = { location: { origin: BASE_ORIGIN, href: BASE_ORIGIN + exhibitPath }, listeners: new Set() };
ngn.parent = ngn;
exhibit.parent = ngn;
for (const w of [ngn, exhibit]) {
w.addEventListener = (type, fn) => { if (type === 'message') w.listeners.add(fn); };
w.removeEventListener = (type, fn) => { if (type === 'message') w.listeners.delete(fn); };
}
// Real postMessage semantics: targetOrigin is checked at delivery time,
// delivery is asynchronous, and event.source is the caller's own window
// object -- never something the message payload can spoof.
exhibit.postMessage = (data, targetOrigin) => {
if (targetOrigin !== '*' && targetOrigin !== exhibit.location.origin) return;
let payload = data;
if (rewriteOutgoing) payload = rewriteOutgoing(payload) || payload;
const cloned = JSON.parse(JSON.stringify(payload));
setTimeout(() => { for (const fn of [...exhibit.listeners]) fn({ origin: ngn.location.origin, source: ngn, data: cloned }); }, 0);
};
ngn.postMessage = (data, targetOrigin) => {
if (targetOrigin !== '*' && targetOrigin !== ngn.location.origin) return;
const cloned = JSON.parse(JSON.stringify(data));
setTimeout(() => { for (const fn of [...ngn.listeners]) fn({ origin: exhibit.location.origin, source: exhibit, data: cloned }); }, 0);
};
return { ngn, exhibit };
}
/** Boots the real shared-core exhibit-side scripts (unmodified) in their own
* vm realm, with `window` bound to the fake-but-spec-faithful exhibit window. */
function bootSharedCoreExhibit(exhibitWindow, files) {
const ctx = vm.createContext({ window: exhibitWindow, setTimeout, clearTimeout, console, Date, Math });
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
vm.runInContext(source(SHARED, 'host-transport.js'), ctx);
for (const file of files) vm.runInContext(source(ROOT, file), ctx);
return ctx;
}
async function connectThroughRealTransport({ exhibitPath, bootFiles, createCore, rewriteOutgoing }) {
const { ngn, exhibit } = makeWindowPair(exhibitPath, { rewriteOutgoing });
const ctx = bootSharedCoreExhibit(exhibit, bootFiles);
const core = createCore(ctx);
const hostTransport = new ctx.window.XZBTHostTransport({ core });
const frame = { src: exhibit.location.href, contentWindow: exhibit };
const transport = postMessageTransport(frame, ngn);
const host = new ExhibitHost({ timeoutMs: 2000 });
await host.connect(transport, exhibit.location.href);
return { host, core, hostTransport };
}
/** Boots the real, self-contained SciFi-XZBT contract-adapter.js (unmodified)
* -- this adapter does NOT use the shared contract-core.js/host-transport.js
* at all; it is its own complete Contract implementation with its own
* window-message bridge (`_setupWindowBridge`). Minimal, real (not
* hand-rolled-protocol) bindings are supplied so `state.get` and `set` round
* trip through actual application-shaped state instead of the adapter's own
* empty {} default -- the bindings only stand in for SciFi's app.js/audio.js
* runtime, never for any part of the contract adapter itself. */
function defaultValueFor(target) {
if (target.kind === 'range') return target.min;
if (target.kind === 'selection') return target.options[0].value;
if (target.kind === 'state') {
if (target.valueType === 'boolean') return false;
if (target.valueType === 'string') return '';
return 0;
}
return null;
}
function bootSciFiAdapter(exhibitWindow) {
const ctx = vm.createContext({ window: exhibitWindow, setTimeout, clearTimeout, console, Date, Math });
vm.runInContext(source(ROOT, 'scifi/js/contract-adapter.js'), ctx);
const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT' });
const state = new Map();
for (const target of adapter.targets.values()) {
if (target.readable && target.kind !== 'impulse') state.set(target.id, defaultValueFor(target));
}
adapter.bindings.getState = () => Object.fromEntries(state);
adapter.bindings.setters = new Proxy({}, { get: (_t, id) => (value) => { state.set(id, value); } });
return { ctx, adapter };
}
async function connectToSciFiThroughRealTransport({ rewriteOutgoing } = {}) {
const { ngn, exhibit } = makeWindowPair('/test-fixtures/reference-exhibits/scifi/index.html', { rewriteOutgoing });
const { adapter } = bootSciFiAdapter(exhibit); // constructor wires the real _setupWindowBridge()
const frame = { src: exhibit.location.href, contentWindow: exhibit };
const transport = postMessageTransport(frame, ngn);
const host = new ExhibitHost({ timeoutMs: 2000 });
await host.connect(transport, exhibit.location.href);
return { host, adapter };
}
test('NGN hello reaches a real Contract 5.3 Museum Gallery iframe over the real postMessage transport, session establishes, describe surfaces surfaces', async (t) => {
const { host, hostTransport } = await connectThroughRealTransport({
exhibitPath: '/test-fixtures/reference-exhibits/museum-gallery/control.html',
bootFiles: ['museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js'],
createCore: (ctx) => {
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
return ctx.window.MuseumGalleryContract.create(gallery);
}
});
t.after(() => host.disconnect());
assert.equal(host.status, 'connected');
assert.equal(hostTransport.connected, true, 'host-transport must have accepted the hello and replied');
assert.equal(host.contract.major, 5);
assert.equal(host.contract.minor, 3);
assert.equal(host.sync, 'synchronized');
assert.ok(Array.isArray(host.surfaces), 'describe() must surface Contract 5.3 presentation surfaces');
const ids = host.surfaces.map((s) => s.id).sort();
assert.deepEqual(ids, ['surface.artifact', 'surface.control', 'surface.info-wall']);
});
/* ------------------------------------------------------------------ *
* SciFi-XZBT: the real bridge, over the real transport, forward on
* Contract 5.3 -- this is the fix for the "hello timed out" regression.
* ------------------------------------------------------------------ */
test('NGN hello reaches the real SciFi-XZBT bridge over the real postMessage transport and negotiates Contract 5.3', async (t) => {
const { host, adapter } = await connectToSciFiThroughRealTransport();
t.after(() => host.disconnect());
assert.equal(host.status, 'connected', 'hello must not time out against the real SciFi bridge');
assert.equal(adapter.sessionActive, true, 'the real adapter must have accepted the hello and opened a session');
assert.equal(host.contract.major, 5);
assert.equal(host.contract.minor, 3, 'SciFi-XZBT is a maintained exhibit and must negotiate the current contract, not stay pinned to 5.2');
assert.equal(host.sessionId, adapter.sessionId);
});
test('SciFi-XZBT: describe, state.get and synchronization all complete over the real transport', async (t) => {
const { host } = await connectToSciFiThroughRealTransport();
t.after(() => host.disconnect());
assert.equal(host.sync, 'synchronized', 'connect() must run describe + state.get and reach synchronized');
assert.ok(host.catalog.length > 0, 'describe.result must report a non-empty target catalog');
assert.ok(host.capabilities.length > 0, 'describe.result must report capabilities');
assert.ok(host.values.size > 0, 'state.get must report readable persistent state');
assert.ok(host.values.has('mix.master'), 'a known SciFi target must be present in the snapshot');
});
test('SciFi-XZBT: a representative command (set on a harmless mixer target) round-trips after synchronization', async (t) => {
const { host } = await connectToSciFiThroughRealTransport();
t.after(() => host.disconnect());
assert.equal(host.sync, 'synchronized');
const before = host.stateRevision;
await host.set('mix.master', 0.42);
assert.equal(host.values.get('mix.master'), 0.42, 'the set value must be reflected in NGN\'s cache');
assert.ok(host.stateRevision > before, 'a genuine value change must advance stateRevision, proving more than handshake-only connectivity');
await assert.doesNotReject(host.invoke('sfx.comm-badge', {}), 'a zero-argument impulse must invoke cleanly through the real bridge');
});
/* ------------------------------------------------------------------ *
* Regression coverage for the exact bug class: `xzbt` must never be an
* equality gate, anywhere a maintained exhibit or the shared transport
* decides whether to accept a message.
* ------------------------------------------------------------------ */
test('Regression: no maintained bridge source gates on exact equality against the advisory `xzbt` field', () => {
// Matches `<something>.xzbt <op> '<quoted 5.x>'` (in either operand order)
// for op in {===, !==, ==, !=}. This is a static guard against exactly the
// bug class fixed here: the field is metadata (Contract §6.5), never a
// condition a bridge is allowed to branch on to accept or reject a message.
const gatePattern = /\.xzbt\s*(===|!==|==|!=)\s*['"]5\.\d['"]|['"]5\.\d['"]\s*(===|!==|==|!=)\s*[\w.]*\.xzbt\b/;
const files = [
[SHARED, 'contract-core.js'],
[SHARED, 'host-transport.js'],
[ROOT, 'scifi/js/contract-adapter.js'],
[ROOT, 'museum-gallery/contract-adapter.js'],
[ROOT, 'haunted-house/contract-adapter.js'],
[ROOT, 'aquarium/contract-adapter.js'],
[ROOT, 'planetarium/contract-adapter.js']
];
for (const [base, file] of files) {
const text = source(base, file);
assert.ok(!gatePattern.test(text), `${file} must not gate on exact equality against the advisory xzbt field`);
}
});
test('Regression: Museum Gallery (shared-core) still negotiates when NGN stamps an unexpected advisory xzbt tag', async (t) => {
const { host, hostTransport } = await connectThroughRealTransport({
exhibitPath: '/test-fixtures/reference-exhibits/museum-gallery/control.html',
bootFiles: ['museum-gallery/exhibit.js', 'museum-gallery/contract-adapter.js'],
createCore: (ctx) => {
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
return ctx.window.MuseumGalleryContract.create(gallery);
},
// A caller stamping a nonsense advisory tag is still a well-formed,
// major-5-compatible request. If anything on the exhibit side were
// still comparing this string for equality, this would time out again.
rewriteOutgoing: (message) => ({ ...message, xzbt: 'not-a-real-version' })
});
t.after(() => host.disconnect());
assert.equal(host.status, 'connected', 'an unrecognized advisory xzbt tag must not block negotiation');
assert.equal(hostTransport.connected, true);
assert.equal(host.contract.minor, 3);
});
test('Regression: SciFi-XZBT still negotiates when NGN stamps an unexpected advisory xzbt tag', async (t) => {
const { host, adapter } = await connectToSciFiThroughRealTransport({
rewriteOutgoing: (message) => ({ ...message, xzbt: 'not-a-real-version' })
});
t.after(() => host.disconnect());
assert.equal(host.status, 'connected', 'an unrecognized advisory xzbt tag must not block the real SciFi bridge either');
assert.equal(adapter.sessionActive, true);
assert.equal(host.contract.minor, 3);
});
+535
View File
@@ -0,0 +1,535 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
/*
* Step 6.7B -- SciFi Observation Surface Integration.
*
* SciFi-XZBT's contract-adapter.js is self-contained (no shared
* test-fixtures/reference-exhibits/shared/contract-core.js dependency,
* unlike Museum Gallery) -- it is one IIFE publishing window.XZBTContractAdapter
* directly. surface-mode.js and surface-bus.js are likewise standalone, pure
* files with no imports, which is what makes them separately loadable and
* testable under vm here even though js/app.js (DOM-bound, ~4900 lines) is
* not. See docs/architecture/XZBT-NGN-Step6.7A-SciFi-Observation-Surface.md
* SS14 for the full test plan this file implements a representative subset of.
*/
const ROOT = new URL('../test-fixtures/reference-exhibits/scifi/', import.meta.url);
function source(file) {
return readFileSync(new URL(file, ROOT), 'utf8');
}
/** Strips vm-realm object identity so assert.deepEqual compares plain
* host-realm structures (same technique as museum-gallery.test.js). */
function plain(value) {
return JSON.parse(JSON.stringify(value));
}
function makeContext(extra = {}) {
return vm.createContext({
window: {},
BroadcastChannel,
URLSearchParams,
setTimeout,
clearTimeout,
Math,
Date,
console,
...extra
});
}
/** Loads surface-mode.js + contract-adapter.js only -- structurally
* incapable of constructing app.js's audio/AI/visualizer subsystems,
* exactly as makeSurfaceContext() does for Museum Gallery. */
function makeAdapterContext() {
const ctx = makeContext();
vm.runInContext(source('js/surface-mode.js'), ctx);
vm.runInContext(source('js/contract-adapter.js'), ctx);
return ctx;
}
function sciFiCatalog(instanceId = 'xi-test-instance') {
const ctx = makeAdapterContext();
const surfaces = ctx.window.XZBTSurfaceMode.SURFACES(instanceId);
const adapter = new ctx.window.XZBTContractAdapter({
product: 'SciFi-XZBT', version: '5.3.0', surfaces, instanceId, bindings: {}
});
return { ctx, adapter, surfaces: plain(surfaces), description: plain(adapter.describe()) };
}
function makeBusContext() {
const ctx = makeContext();
vm.runInContext(source('js/surface-bus.js'), ctx);
return ctx;
}
function closeAll(...handles) {
for (const h of handles) {
try {
if (!h) continue;
if (typeof h.detach === 'function') h.detach();
else if (h.owner && typeof h.owner.detach === 'function') h.owner.detach();
else if (h.link && typeof h.link.detach === 'function') h.link.detach();
} catch {
/* best-effort cleanup */
}
}
}
function waitFor(predicate, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
(function poll() {
if (predicate()) return resolve();
if (Date.now() > deadline) return reject(new Error('timed out waiting for condition'));
setTimeout(poll, 5);
})();
});
}
/* ------------------------------------------------------------------ *
* 1-7: Catalog and contract conformance
* ------------------------------------------------------------------ */
test('describe() includes a surfaces array with exactly one primary and only documented §31.2 fields', () => {
const { description } = sciFiCatalog();
assert.ok(Array.isArray(description.surfaces));
assert.equal(description.surfaces.length, 2);
const ids = description.surfaces.map((s) => s.id).sort();
assert.deepEqual(ids, ['surface.console', 'surface.observation']);
const primaries = description.surfaces.filter((s) => s.primary === true);
assert.equal(primaries.length, 1, 'exactly one primary surface');
assert.equal(primaries[0].id, 'surface.console');
assert.equal(primaries[0].url, 'index.html');
const ALLOWED = new Set(['id', 'label', 'kind', 'primary', 'url', 'role', 'category',
'aspectRatio', 'requires', 'description']);
for (const s of description.surfaces) {
assert.equal(s.kind, 'surface');
assert.match(s.id, /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, 'canonical dotted id grammar');
for (const key of Object.keys(s)) {
assert.ok(ALLOWED.has(key), `unexpected field '${key}' on ${s.id}`);
}
}
});
test('the observation surface url is a bare query string against the same document, carrying the instance id', () => {
const { description } = sciFiCatalog('xi-abc123');
const observation = description.surfaces.find((s) => s.id === 'surface.observation');
assert.equal(observation.primary, false);
assert.equal(observation.url, '?surface=observation&xi=xi-abc123');
assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(observation.url), 'url must not carry a scheme');
assert.ok(observation.url.indexOf('//') !== 0, 'url must not be protocol-relative');
assert.deepEqual(observation.requires, ['observation']);
});
test('constructing the adapter with no surfaces option omits `surfaces` entirely (§31.3 form 1 preserved)', () => {
const ctx = makeAdapterContext();
const adapter = new ctx.window.XZBTContractAdapter({ product: 'SciFi-XZBT', version: '5.3.0', bindings: {} });
const description = plain(adapter.describe());
assert.equal('surfaces' in description, false);
assert.equal(description.contract.major, 5);
assert.equal(description.exhibit.version, '5.3.0');
});
test('view.pillars and view.warp-flight are readable/writable boolean state targets and appear in the snapshot key set', () => {
const { ctx, adapter } = sciFiCatalog();
const pillars = adapter.targets.get('view.pillars');
const warp = adapter.targets.get('view.warp-flight');
for (const target of [pillars, warp]) {
assert.ok(target, 'target must be registered');
assert.equal(target.kind, 'state');
assert.equal(target.valueType, 'boolean');
assert.equal(target.readable, true);
assert.equal(target.writable, true);
assert.equal(target.restorable, true);
assert.equal(target.category, 'observation');
assert.deepEqual(plain(target.requires), ['observation']);
}
const description = plain(adapter.describe());
const ids = description.targets.map((t) => t.id);
assert.ok(ids.includes('view.pillars'));
assert.ok(ids.includes('view.warp-flight'));
void ctx;
});
test('registryRevision stays 1 -- the new targets are a build-time addition, not a runtime registry change', () => {
const { adapter } = sciFiCatalog();
assert.equal(adapter.registryRevision, 1);
});
/* ------------------------------------------------------------------ *
* 8-9: Mode resolution (pure, no DOM)
* ------------------------------------------------------------------ */
test('mode resolution: no query -> console; ?surface=observation -> observation; unknown values -> console', () => {
const ctx = makeContext();
vm.runInContext(source('js/surface-mode.js'), ctx);
const resolve = ctx.window.XZBTSurfaceMode.resolve;
assert.equal(resolve({ search: '' }).mode, 'console');
assert.equal(resolve({ search: '?foo=bar' }).mode, 'console');
assert.equal(resolve({ search: '?surface=bogus' }).mode, 'console');
assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).mode, 'observation');
assert.equal(resolve({ search: '?surface=observation&xi=xi-1' }).instanceId, 'xi-1');
// Order and extra params are tolerated.
assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).mode, 'observation');
assert.equal(resolve({ search: '?xi=xi-2&extra=1&surface=observation' }).instanceId, 'xi-2');
});
test('channelName is instance-scoped: two instance ids yield two different channel names', () => {
const ctx = makeContext();
vm.runInContext(source('js/surface-mode.js'), ctx);
const { channelName, newInstanceId } = ctx.window.XZBTSurfaceMode;
const a = newInstanceId();
const b = newInstanceId();
assert.notEqual(a, b);
assert.notEqual(channelName(a), channelName(b));
assert.ok(channelName(a).startsWith('xzbt-scifi-surface-v1:'));
});
/* ------------------------------------------------------------------ *
* 10-17: Bus / synchronization (surface-bus.js against a minimal stub
* adapter -- exercises the bus mechanism itself, independent of app.js)
* ------------------------------------------------------------------ */
/** A minimal stand-in for XZBTContractAdapter: just enough surface for
* surface-bus.js's owner side (getContractState/applyMutation/invokeAction/
* targets/stateRevision/registryRevision), with the same session-independent
* onLocalChange/onLocalAction contract the real adapter now provides. */
function makeStubAdapter() {
const targets = new Map([
['view.pillars', { id: 'view.pillars', kind: 'state', readable: true, writable: true }],
['preset.selected', { id: 'preset.selected', kind: 'selection', readable: true, writable: true }]
]);
const state = { 'view.pillars': false, 'preset.selected': 'a' };
const adapter = {
targets,
stateRevision: 0,
registryRevision: 1,
onLocalChange: null,
onLocalAction: null,
getContractState: () => ({ ...state }),
applyMutation: (id, value) => {
if (!targets.has(id)) return { ok: false };
state[id] = value;
adapter.stateRevision += 1;
if (adapter.onLocalChange) adapter.onLocalChange(id, value, adapter.stateRevision);
return { ok: true };
},
invokeAction: (id, args) => {
if (adapter.onLocalAction) adapter.onLocalAction(id, args || {});
return { ok: true };
}
};
return adapter;
}
function makeOwner(channelName) {
const ctx = makeBusContext();
const adapter = makeStubAdapter();
const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName });
adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev);
adapter.onLocalAction = (id, args) => owner.broadcastAction(id, args);
return { ctx, adapter, owner };
}
function attachSurface(channelName, timeoutMs) {
const ctx = makeBusContext();
const changes = [];
const actions = [];
const presentations = [];
return new Promise((resolve, reject) => {
const link = ctx.window.XZBTSurfaceBus.attach({
channelName,
timeoutMs: timeoutMs || 500,
onSnapshot: (values, stateRevision, registryRevision, presentation) =>
resolve({ ctx, link, changes, actions, presentations, values: plain(values), stateRevision, registryRevision, presentation: plain(presentation) }),
onChange: (target, value, stateRevision) => changes.push({ target, value, stateRevision }),
onAction: (target, args) => actions.push({ target, args: plain(args) }),
onPresentation: (kind, payload) => presentations.push({ kind, payload: plain(payload) }),
onTimeout: () => reject(new Error('attach timed out waiting for an owner'))
});
});
}
test('late join: attach after a mutation carries the current value and stateRevision in the snapshot', async () => {
const channel = 'xzbt-scifi-surface-v1:test-latejoin-' + Math.random();
const { adapter, owner } = makeOwner(channel);
let surface;
try {
adapter.applyMutation('view.pillars', true);
surface = await attachSurface(channel);
assert.equal(surface.values['view.pillars'], true);
assert.equal(surface.stateRevision, adapter.stateRevision);
} finally {
closeAll(owner, surface);
}
});
test('propagation: an authority mutation reaches an attached surface as one state message with the post-mutation value', async () => {
const channel = 'xzbt-scifi-surface-v1:test-propagate-' + Math.random();
const { adapter, owner } = makeOwner(channel);
let surface;
try {
surface = await attachSurface(channel);
adapter.applyMutation('preset.selected', 'b');
await waitFor(() => surface.changes.some((c) => c.target === 'preset.selected' && c.value === 'b'));
const matches = surface.changes.filter((c) => c.target === 'preset.selected');
assert.equal(matches.length, 1);
assert.equal(matches[0].stateRevision, adapter.stateRevision);
} finally {
closeAll(owner, surface);
}
});
test('mutation routing: a surface-side mutate results in exactly one applyMutation call and converges on a second attached surface', async () => {
const channel = 'xzbt-scifi-surface-v1:test-routing-' + Math.random();
const { adapter, owner } = makeOwner(channel);
let applyCount = 0;
const origApply = adapter.applyMutation;
adapter.applyMutation = (...args) => { applyCount += 1; return origApply(...args); };
let surfaceA, surfaceB;
try {
surfaceA = await attachSurface(channel);
surfaceB = await attachSurface(channel);
surfaceA.link.mutate('set', 'view.pillars', true);
await waitFor(() => surfaceB.changes.some((c) => c.target === 'view.pillars' && c.value === true));
assert.equal(applyCount, 1);
assert.equal(adapter.getContractState()['view.pillars'], true, 'authority converged');
assert.ok(surfaceA.changes.some((c) => c.target === 'view.pillars' && c.value === true), 'originating surface also converges (no local write)');
} finally {
closeAll(owner, surfaceA, surfaceB);
}
});
test('no second authority: a surface-side mutate with no owner present is a no-op and the surface stays in the waiting state', async () => {
const channel = 'xzbt-scifi-surface-v1:test-noowner-' + Math.random();
const ctx = makeBusContext();
let timedOut = false;
const link = await new Promise((resolve) => {
const l = ctx.window.XZBTSurfaceBus.attach({
channelName: channel,
timeoutMs: 60,
onSnapshot: () => resolve(l),
onTimeout: () => { timedOut = true; resolve(l); }
});
});
try {
assert.equal(timedOut, true);
assert.equal(link.isAttached(), false);
assert.equal(link.mutate('set', 'view.pillars', true), false, 'mutate before/without attachment is a no-op');
} finally {
closeAll(link);
}
});
test('reload: re-attaching with a fresh participantId yields the current snapshot and does not ratchet the participant count', async () => {
const channel = 'xzbt-scifi-surface-v1:test-reload-' + Math.random();
const { adapter, owner } = makeOwner(channel);
let first, second;
try {
first = await attachSurface(channel);
assert.equal(owner.attachedCount(), 1);
first.link.detach();
await waitFor(() => owner.attachedCount() === 0);
adapter.applyMutation('view.pillars', true);
second = await attachSurface(channel);
assert.equal(owner.attachedCount(), 1, 'reload does not ratchet the count');
assert.equal(second.values['view.pillars'], true, 'reattach observes current state');
} finally {
closeAll(owner, first, second);
}
});
test('detach is idempotent, never mutates state, and cannot underflow the participant count', async () => {
const channel = 'xzbt-scifi-surface-v1:test-detach-' + Math.random();
const { adapter, owner } = makeOwner(channel);
let surface;
try {
surface = await attachSurface(channel);
const revBefore = adapter.stateRevision;
surface.link.detach();
surface.link.detach(); // duplicate detach must not underflow
await waitFor(() => owner.attachedCount() === 0);
assert.equal(owner.attachedCount(), 0);
assert.equal(adapter.stateRevision, revBefore, 'detaching must not itself mutate state');
} finally {
closeAll(owner, surface);
}
});
test('session independence: onLocalChange fires regardless of any session concept -- the bus has no idea NGN exists', async () => {
// The stub adapter here has no sessionActive/eventSequence at all, which is
// the point: onLocalChange is a plain method call, not gated on a session,
// unlike the real adapter's _emitEvent (contract-adapter.js §5.4).
const channel = 'xzbt-scifi-surface-v1:test-sessionindep-' + Math.random();
const { adapter, owner } = makeOwner(channel);
let surface;
try {
surface = await attachSurface(channel);
adapter.applyMutation('view.pillars', true);
await waitFor(() => surface.changes.length > 0);
assert.equal(surface.changes[0].target, 'view.pillars');
} finally {
closeAll(owner, surface);
}
});
test('presentation messages (ticker, obs-activity) are relayed to attached surfaces and are not contract state', async () => {
const channel = 'xzbt-scifi-surface-v1:test-presentation-' + Math.random();
const { owner } = makeOwner(channel);
let surface;
try {
surface = await attachSurface(channel);
owner.broadcastPresentation('ticker', { tickerText: 'HELLO WORLD' });
owner.broadcastPresentation('obs-activity', { source: 'ambient' });
await waitFor(() => surface.presentations.length >= 2);
assert.deepEqual(surface.presentations[0], { kind: 'ticker', payload: { type: 'presentation', kind: 'ticker', tickerText: 'HELLO WORLD' } });
assert.equal(surface.presentations[1].kind, 'obs-activity');
} finally {
closeAll(owner, surface);
}
});
/* ------------------------------------------------------------------ *
* 18: Duplicate-subsystem prevention -- construction spy on surface-bus.js
* ------------------------------------------------------------------ */
test('surface-bus.js never touches AudioContext, fetch or dynamic import, in either owner or surface role', async () => {
let audioContextConstructions = 0;
const ctx = vm.createContext({
window: {},
BroadcastChannel,
setTimeout, clearTimeout, Math, Date, console,
AudioContext: class { constructor() { audioContextConstructions += 1; } },
fetch: () => { throw new Error('surface-bus.js must never fetch'); }
});
vm.runInContext(source('js/surface-bus.js'), ctx);
const channel = 'xzbt-scifi-surface-v1:test-spy-' + Math.random();
const adapter = makeStubAdapter();
const owner = ctx.window.XZBTSurfaceBus.createOwner(adapter, { channelName: channel });
adapter.onLocalChange = (id, value, rev) => owner.broadcastState(id, value, rev);
let link;
try {
link = await new Promise((resolve, reject) => {
const l = ctx.window.XZBTSurfaceBus.attach({
channelName: channel, timeoutMs: 500,
onSnapshot: () => resolve(l),
onTimeout: () => reject(new Error('attach timed out'))
});
});
link.mutate('set', 'view.pillars', true);
await waitFor(() => adapter.getContractState()['view.pillars'] === true);
assert.equal(audioContextConstructions, 0);
} finally {
closeAll(owner, link);
}
});
/* ------------------------------------------------------------------ *
* 19-21: app.js is too DOM-bound for vm (per the architecture doc's own
* assessment) -- these are asserted as source-structure tests, the same
* technique museum-gallery.test.js uses for "exactly one Exhibit State
* Core exists".
* ------------------------------------------------------------------ */
function appJsSource() {
return source('js/app.js');
}
test('source structure: enterObservation() only calls prepareExperience() inside an isConsoleMode guard', () => {
const src = appJsSource();
const fnStart = src.indexOf('function enterObservation()');
assert.ok(fnStart >= 0, 'enterObservation() must exist');
const fnEnd = src.indexOf('\n function exitObservation()', fnStart);
assert.ok(fnEnd > fnStart);
const body = src.slice(fnStart, fnEnd);
// Search for the real call site, not the word appearing in a comment.
const callIdx = body.indexOf('generativeExperience.prepareExperience()');
assert.ok(callIdx >= 0, 'enterObservation() must still prepare the generative experience somewhere');
const guardIdx = body.lastIndexOf('if (isConsoleMode) {', callIdx);
assert.ok(guardIdx >= 0 && guardIdx < callIdx, 'prepareExperience() must be reached only through an isConsoleMode guard');
// The guard's closing brace must come after the call (i.e. the call is
// actually nested inside the guard, not merely preceded by one elsewhere).
const closeIdx = body.indexOf('\n }', callIdx);
assert.ok(closeIdx > callIdx, 'the isConsoleMode guard must close after the prepareExperience() call');
});
test('source structure: XZBTGenerativeExperience, XZBTContractAdapter, XZBTControlBus and StarshipVisualizer construction sites are each reached only through an isConsoleMode check', () => {
const src = appJsSource();
for (const ctor of ['new XZBTGenerativeExperience(', 'new XZBTControlBus(', 'new XZBTContractAdapter(', 'new StarshipVisualizer(']) {
const idx = src.indexOf(ctor);
assert.ok(idx >= 0, `${ctor} construction site must exist`);
// The isConsoleMode identifier (either as an if-guard or a ternary
// condition) must appear on a line at or before the construction site,
// within a reasonably tight window -- resilient to exact formatting,
// unlike a brittle single-line regex.
const window_ = src.slice(Math.max(0, idx - 400), idx);
assert.ok(window_.includes('isConsoleMode'), `${ctor} must be structurally gated on isConsoleMode`);
}
});
test('source structure: the keydown hotkey listener is installed only inside an isConsoleMode guard', () => {
const src = appJsSource();
const idx = src.indexOf("window.addEventListener('keydown'");
assert.ok(idx >= 0);
const before = src.slice(Math.max(0, idx - 200), idx);
assert.ok(before.includes('if (isConsoleMode)'), 'keydown listener must be console-mode only');
});
test('source structure: scheduleObservationAmbientActivity() refuses to run at all outside console mode (no independent surface-side timer)', () => {
const src = appJsSource();
const fnStart = src.indexOf('function scheduleObservationAmbientActivity()');
assert.ok(fnStart >= 0);
const fnBody = src.slice(fnStart, fnStart + 600);
assert.match(fnBody, /if\s*\(!isConsoleMode\)\s*return;/, 'the scheduler must early-return outside console mode');
});
test('source structure: the observation-audience predicate considers attached surfaces, not just the local overlay flag', () => {
const src = appJsSource();
assert.match(src, /observationAudienceActive\s*=\s*\(\)\s*=>\s*observationActive\s*\|\|/, 'audience-active must OR in attached-surface state');
assert.match(src, /surfaceOwner\s*&&\s*surfaceOwner\.attachedCount\(\)\s*>\s*0/, 'must consult surfaceOwner.attachedCount()');
});
test('source structure: opening the surface never sets view.observation (surface lifecycle and the console overlay flag stay distinct)', () => {
const src = appJsSource();
// The presentation attach path's onSnapshot handler must call
// enterObservation() (unconditional local render) and must never call
// applyMutation('view.observation', ...) or mutate('set', 'view.observation', ...).
const snapshotIdx = src.indexOf('onSnapshot: (values, stateRevision, registryRevision, presentation)');
assert.ok(snapshotIdx >= 0);
const handlerEnd = src.indexOf('onChange:', snapshotIdx);
const handlerBody = src.slice(snapshotIdx, handlerEnd);
assert.ok(handlerBody.includes('enterObservation()'));
assert.ok(!handlerBody.includes("'view.observation'"), 'the snapshot handler must never touch view.observation');
});
test('source structure: presentation-side dock routing excludes view.observation from the routed target set', () => {
const src = appJsSource();
const idx = src.indexOf('XZBT_PRESENTATION_ROUTED_TARGETS');
assert.ok(idx >= 0);
const block = src.slice(idx, idx + 300);
assert.ok(!block.includes("'view.observation'"), 'view.observation must never be routed from the surface to the owner');
for (const target of ['view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected']) {
assert.ok(block.includes(`'${target}'`), `${target} must be routed`);
}
});
test('source structure: contract-adapter.js exposes onLocalChange/onLocalAction independent of the sessionActive gate on _emitEvent', () => {
const src = source('js/contract-adapter.js');
assert.match(src, /if\s*\(!this\.sessionActive\)\s*return;/, '_emitEvent\'s session gate must remain');
assert.match(src, /this\.onLocalChange\s*\(/, 'onLocalChange must be invoked');
assert.match(src, /this\.onLocalAction\s*\(/, 'onLocalAction must be invoked');
// The onLocalChange call site must not itself be behind a sessionActive check.
const idx = src.indexOf('if (this.onLocalChange) {');
assert.ok(idx >= 0);
const nearby = src.slice(Math.max(0, idx - 150), idx);
assert.ok(!nearby.includes('sessionActive'), 'onLocalChange must fire regardless of sessionActive');
});
+8 -3
View File
@@ -1,9 +1,14 @@
/**
* Phase 6.3 Surface catalog validation and lifecycle tests.
*
* Covers Contract 5.3 §§31.2-31.5 normative validation order,
* host state lifecycle, registry refresh behavior, and 5.2 backward
* compatibility. No exhibit-specific IDs appear in this file.
* Covers Contract 5.3 §§31.2-31.5 normative validation order, host state
* lifecycle, registry refresh behavior, and the host's protocol-level
* ability to negotiate with a genuine Contract 5.2 peer (Contract §28.2).
* That is a property of NGN's own negotiation logic against whatever a peer
* reports, using synthetic peer fixtures below -- distinct from, and not an
* argument for, keeping any of this repository's own maintained reference
* exhibits pinned to Contract 5.2. No exhibit-specific IDs appear in this
* file.
*/
import test from 'node:test';
import assert from 'node:assert/strict';