generated from Labyricorn/labyricorn-project-template
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:
@@ -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
|
Reference exhibits under `test-fixtures/` are test/reference fixtures, not product
|
||||||
code. Keep exhibit-specific names and paths in test configuration or documentation.
|
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-NGN Exhibit Engine Implementation Plan v5.2
|
||||||
- XZBT Exhibit Authoring Guide v5.2
|
- XZBT Exhibit Authoring Guide v5.2
|
||||||
- Step 3.7 reference exhibit report
|
- 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.
|
- Do not infer XZBT-NGN behavior from SciFi-XZBT source code.
|
||||||
- SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
|
- SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# XZBT-NGN Exhibit Engine
|
# 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
|
discovers descriptor-driven controls, inspects reported state, sends set/invoke
|
||||||
operations, and displays capabilities, events, revisions, and protocol errors.
|
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.
|
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
|
Step 5 is complete: 27 automated tests and all three browser reference checks
|
||||||
pass. Haunted House reports conformant values, and publishing validation passes.
|
pass. Haunted House reports conformant values, and publishing validation passes.
|
||||||
Step 6 is planned but has not begun.
|
Steps 6.1–6.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
|
## 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.
|
exhibit instance starts with its own initial state.
|
||||||
- **Disconnect** removes the frame and clears session, catalog, state and events.
|
- **Disconnect** removes the frame and clears session, catalog, state and events.
|
||||||
- **Refresh state** reads a new authoritative snapshot.
|
- **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).
|
- Controls are grouped by the exhibit's category (or Other when omitted).
|
||||||
Reported values and draft inputs are distinct; select **Set** or **Invoke**
|
Reported values and draft inputs are distinct; select **Set** or **Invoke**
|
||||||
to submit. Rediscovery resets drafts.
|
to submit. Rediscovery resets drafts.
|
||||||
@@ -41,6 +50,8 @@ Included reference fixture paths:
|
|||||||
/test-fixtures/reference-exhibits/aquarium/index.html
|
/test-fixtures/reference-exhibits/aquarium/index.html
|
||||||
/test-fixtures/reference-exhibits/planetarium/index.html
|
/test-fixtures/reference-exhibits/planetarium/index.html
|
||||||
/test-fixtures/reference-exhibits/haunted-house/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
|
These are locally corrected test/reference fixtures, not NGN product code or
|
||||||
@@ -69,34 +80,55 @@ needed for ordinary operation.
|
|||||||
## Canonical documentation
|
## Canonical documentation
|
||||||
|
|
||||||
This repository is the canonical home for the shared
|
This repository is the canonical home for the shared
|
||||||
[XZBT Exhibit Contract Specification 5.2](docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md)
|
[XZBT Exhibit Contract Specification 5.3](docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md) --
|
||||||
and [XZBT Exhibit Authoring Guide 5.2](docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.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)
|
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;
|
also belongs here. Its historical phase numbering describes the longer roadmap;
|
||||||
the current Step 5 completion pass does not authorize those later features.
|
the current Step 5 completion pass does not authorize those later features.
|
||||||
SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
|
SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
|
||||||
`docs/reference/` contains external implementation context, not host requirements.
|
`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
|
## Implementation and MVP boundary
|
||||||
|
|
||||||
- `src/host.js`: negotiation, requests, discovery, state cache, events and recovery.
|
- `src/host.js`: negotiation, requests, discovery, state cache, events and recovery.
|
||||||
- `src/connection.js`: URL validation and frame load/disconnect/reconnect lifecycle.
|
- `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/validation.js`: descriptor, state value and argument validation.
|
||||||
- `src/transport/post-message.js`: same-origin frame transport.
|
- `src/transport/post-message.js`: same-origin frame transport.
|
||||||
- `src/ui.js`, `public/`: generic operator interface.
|
- `src/ui.js`, `public/`: generic operator interface.
|
||||||
- `server/serve.js`: loopback static server.
|
- `server/serve.js`: loopback static server.
|
||||||
- `tests/`, `test-fixtures/`: automated checks, reference fixtures and browser evidence.
|
- `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
|
state inspection, set/invoke, capabilities, events, session/revision/sequence
|
||||||
tracking, reconnect and synchronization recovery. It has in-memory state only;
|
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
|
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,
|
Not implemented: casting/display endpoints, MIDI, MCP/webhooks,
|
||||||
MCP/webhooks, scenarios/recording, telemetry acquisition, multi-exhibit
|
scenarios/recording, telemetry acquisition, multi-exhibit orchestration,
|
||||||
orchestration, persisted operator configurations, remote deployment, accounts,
|
persisted operator configurations, remote deployment, accounts, databases,
|
||||||
databases, cloud services, plugins, commercial licensing or production branding.
|
cloud services, plugins, commercial licensing or production branding.
|
||||||
|
|
||||||
## Publishing records
|
## 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.3–6.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 ~1261–4494) 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:172–253`.
|
||||||
|
|
||||||
|
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 1–4 | 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 18–19 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 16–19 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.3–6.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
|
# XZBT Exhibit Contract Specification
|
||||||
## Version 5.2
|
## 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
|
**Status:** Proposed normative specification
|
||||||
**Document version:** 5.2
|
**Document version:** 5.2
|
||||||
**Contract family:** XZBT Exhibit Contract
|
**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
@@ -17,7 +17,7 @@
|
|||||||
<div id="catalog">Connect an exhibit to discover its controls.</div>
|
<div id="catalog">Connect an exhibit to discover its controls.</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="surfaces-section"><h2>Presentation surfaces <span id="surface-count"></span></h2>
|
<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>
|
<div id="surfaces"><p>No presentation surfaces advertised.</p></div>
|
||||||
</section>
|
</section>
|
||||||
<section><h2>State and metadata</h2><button id="refresh" disabled>Refresh state</button>
|
<section><h2>State and metadata</h2><button id="refresh" disabled>Refresh state</button>
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export class ExhibitConnection {
|
|||||||
const frame = this.createFrame(); this.frame = frame;
|
const frame = this.createFrame(); this.frame = frame;
|
||||||
frame.addEventListener('load', () => {
|
frame.addEventListener('load', () => {
|
||||||
if (generation !== this.generation) return;
|
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();
|
clearTimeout(this.timer); this.loading = false; this.attach();
|
||||||
});
|
});
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ExhibitHost } from './host.js';
|
|||||||
import { argumentSchema } from './validation.js';
|
import { argumentSchema } from './validation.js';
|
||||||
import { postMessageTransport } from './transport/post-message.js';
|
import { postMessageTransport } from './transport/post-message.js';
|
||||||
import { ExhibitConnection } from './connection.js';
|
import { ExhibitConnection } from './connection.js';
|
||||||
|
import { LocalSurfaces } from './local-surfaces.js';
|
||||||
|
|
||||||
const host = new ExhibitHost();
|
const host = new ExhibitHost();
|
||||||
const byId = id => document.getElementById(id);
|
const byId = id => document.getElementById(id);
|
||||||
@@ -9,6 +10,7 @@ const json = value => JSON.stringify(value, null, 2);
|
|||||||
const rows = new Map();
|
const rows = new Map();
|
||||||
let renderedCatalog = null;
|
let renderedCatalog = null;
|
||||||
let renderedSurfaces = null;
|
let renderedSurfaces = null;
|
||||||
|
const surfaceRows = new Map();
|
||||||
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
|
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
|
||||||
createFrame() {
|
createFrame() {
|
||||||
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
|
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
|
||||||
@@ -17,6 +19,16 @@ const connection = new ExhibitConnection({ host, base: location.href, transport:
|
|||||||
return frame;
|
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) {
|
function element(tag, text, parent) {
|
||||||
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
|
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
|
||||||
parent?.append(node); return node;
|
parent?.append(node); return node;
|
||||||
@@ -102,18 +114,39 @@ function buildCatalog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function buildSurfaces() {
|
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})` : '';
|
byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : '';
|
||||||
if (!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;
|
return;
|
||||||
}
|
}
|
||||||
for (const surface of host.surfaces) {
|
for (const surface of host.surfaces) {
|
||||||
const card = element('article', undefined, container); card.className = 'surface';
|
let row = surfaceRows.get(surface.id);
|
||||||
element('h3', surface.label, card);
|
if (!row) {
|
||||||
element('code', surface.id, card);
|
const card = element('article', undefined, container); card.className = 'surface';
|
||||||
const badges = element('div', undefined, card);
|
const metadata = element('div', undefined, card);
|
||||||
if (surface.primary) { const b = element('span', 'Primary', badges); b.className = 'surface-badge primary'; }
|
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.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'; }
|
if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; }
|
||||||
const meta = [];
|
const meta = [];
|
||||||
@@ -121,7 +154,7 @@ function buildSurfaces() {
|
|||||||
if (surface.url) meta.push(`URL: ${surface.url}`);
|
if (surface.url) meta.push(`URL: ${surface.url}`);
|
||||||
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
|
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
|
||||||
if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`);
|
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() {
|
function render() {
|
||||||
@@ -148,6 +181,18 @@ function render() {
|
|||||||
byId('disconnect').disabled = !connection.frame;
|
byId('disconnect').disabled = !connection.frame;
|
||||||
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
|
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
|
||||||
if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); }
|
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()) {
|
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.';
|
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)) {
|
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
|
||||||
|
|||||||
+11
-26
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveSurfaceURL } from './surface-url.js';
|
||||||
|
|
||||||
export class ProtocolError extends Error {
|
export class ProtocolError extends Error {
|
||||||
constructor(code, message) { super(message); this.name = 'ProtocolError'; this.code = code; }
|
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}`); }
|
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.
|
// 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.
|
// exhibitBaseUrl is optional; when present, §31.4 same-origin resolution is applied.
|
||||||
function invalidSurfaceEntryReason(d, seenIds, exhibitBaseUrl) {
|
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 (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
|
||||||
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
|
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
|
||||||
if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
|
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)';
|
for (const field of ['description', 'role', 'aspectRatio', 'category']) {
|
||||||
if (exhibitBaseUrl) {
|
if (d[field] !== undefined && typeof d[field] !== 'string') return `${field} must be a string`;
|
||||||
const reason = crossOriginReason(d.url, exhibitBaseUrl);
|
|
||||||
if (reason) return reason;
|
|
||||||
}
|
}
|
||||||
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,76 @@ Local compatibility corrections:
|
|||||||
NGN has no fixture-specific target logic. These corrections apply only to
|
NGN has no fixture-specific target logic. These corrections apply only to
|
||||||
the local fixture copies. No upstream fixes or independent conformance
|
the local fixture copies. No upstream fixes or independent conformance
|
||||||
certification are claimed.
|
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.
|
* This file is the only place in the exhibit that knows the contract exists.
|
||||||
* It does three things and nothing else:
|
* 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
|
* 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
|
* exhibit's audio engine cannot start until the browser has seen a user
|
||||||
|
|||||||
@@ -71,6 +71,16 @@
|
|||||||
link.mutate('set', 'artifact.selected', next);
|
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.addEventListener('beforeunload', function () { link.detach(); });
|
||||||
window.__museumGalleryDebug = { link: link };
|
window.__museumGalleryDebug = { link: link };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -126,11 +126,12 @@
|
|||||||
setters: setters,
|
setters: setters,
|
||||||
readers: readers,
|
readers: readers,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
surfaces: surfaces,
|
/* contractMinor/xzbtVersion are intentionally omitted: the shared
|
||||||
/* Contract 5.3 adoption is per-instance; every other reference exhibit
|
* contract-core.js now defaults every exhibit to Contract 5.3, and
|
||||||
* keeps the shared core's Contract 5.2 defaults untouched. */
|
* Museum Gallery has no reason to override that default. `surfaces`
|
||||||
contractMinor: 3,
|
* is the one thing that makes this exhibit's contract usage different
|
||||||
xzbtVersion: '5.3'
|
* from the others -- Contract 5.3 §31 presentation surfaces. */
|
||||||
|
surfaces: surfaces
|
||||||
});
|
});
|
||||||
|
|
||||||
return core;
|
return core;
|
||||||
|
|||||||
@@ -81,12 +81,17 @@
|
|||||||
* surface) to attached surfaces, and answers their attach requests with a
|
* surface) to attached surfaces, and answers their attach requests with a
|
||||||
* fresh snapshot read straight from this Core -- never a second
|
* fresh snapshot read straight from this Core -- never a second
|
||||||
* computation. */
|
* 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;
|
var priorOnEvent = core.onEvent;
|
||||||
core.onEvent = function (event) {
|
core.onEvent = function (event) {
|
||||||
if (typeof priorOnEvent === 'function') priorOnEvent(event);
|
if (typeof priorOnEvent === 'function') priorOnEvent(event);
|
||||||
sync();
|
sync();
|
||||||
Shell.setText(subscriberCountEl, String(bus.attachedCount()));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Optional NGN attachment -- same optional transport every reference
|
/* Optional NGN attachment -- same optional transport every reference
|
||||||
|
|||||||
@@ -56,6 +56,12 @@
|
|||||||
link.mutate('set', 'labels.enabled', !latest['labels.enabled']);
|
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.addEventListener('beforeunload', function () { link.detach(); });
|
||||||
window.__museumGalleryDebug = { link: link };
|
window.__museumGalleryDebug = { link: link };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -39,23 +39,42 @@
|
|||||||
* second computation of state.
|
* second computation of state.
|
||||||
*
|
*
|
||||||
* @param {object} core an XZBTContractCore.ContractCore instance
|
* @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 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) {
|
channel.onmessage = function (ev) {
|
||||||
var msg = ev.data;
|
var msg = ev.data;
|
||||||
if (!msg || typeof msg !== 'object') return;
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
|
||||||
if (msg.type === 'attach') {
|
if (msg.type === 'attach') {
|
||||||
attachedCount += 1;
|
if (typeof msg.participantId === 'string') {
|
||||||
|
participants.add(msg.participantId);
|
||||||
|
}
|
||||||
channel.postMessage({
|
channel.postMessage({
|
||||||
type: 'attach.snapshot',
|
type: 'attach.snapshot',
|
||||||
inReplyTo: msg.requestId,
|
inReplyTo: msg.requestId,
|
||||||
snapshot: core.stateSnapshot(),
|
snapshot: core.stateSnapshot(),
|
||||||
registryRevision: core.registryRevision
|
registryRevision: core.registryRevision
|
||||||
});
|
});
|
||||||
|
notifyConnectionChange();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +94,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'detach') {
|
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 {
|
return {
|
||||||
channel: channel,
|
channel: channel,
|
||||||
attachedCount: function () { return attachedCount; },
|
attachedCount: function () { return participants.size; },
|
||||||
close: function () { channel.close(); }
|
close: function () { channel.close(); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,7 +134,14 @@
|
|||||||
options = options || {};
|
options = options || {};
|
||||||
var channel = new BroadcastChannel(CHANNEL_NAME);
|
var channel = new BroadcastChannel(CHANNEL_NAME);
|
||||||
var requestId = randomId();
|
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 attached = false;
|
||||||
|
var detached = false;
|
||||||
|
|
||||||
var timer = setTimeout(function () {
|
var timer = setTimeout(function () {
|
||||||
if (attached) return;
|
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 {
|
return {
|
||||||
channel: channel,
|
channel: channel,
|
||||||
@@ -148,11 +194,7 @@
|
|||||||
channel.postMessage(payload);
|
channel.postMessage(payload);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
detach: function () {
|
detach: detach
|
||||||
if (attached) channel.postMessage({ type: 'detach' });
|
|
||||||
clearTimeout(timer);
|
|
||||||
channel.close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Declares the catalog, binds targets to real exhibit services, and routes
|
||||||
* the native UI through the canonical mutation path. No state of its own.
|
* 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){
|
@media (max-width:700px){
|
||||||
.watch-experience-wrap{min-width:150px}.watch-experience-help{font-size:.54rem}
|
.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-bezels.js"></script>
|
||||||
<script src="js/observation-engine.js"></script>
|
<script src="js/observation-engine.js"></script>
|
||||||
<script src="js/control-bus.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/generative-experience.js"></script>
|
||||||
<script src="js/contract-adapter.js"></script>
|
<script src="js/contract-adapter.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
|||||||
@@ -5,6 +5,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
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
|
// 1. Initialize Audio Subsystems
|
||||||
const audioManager = new AudioManager();
|
const audioManager = new AudioManager();
|
||||||
const fncSystem = new FuturisticNoiceCancellation(audioManager);
|
const fncSystem = new FuturisticNoiceCancellation(audioManager);
|
||||||
@@ -21,7 +35,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.expandedAudio = expandedAudio;
|
window.expandedAudio = expandedAudio;
|
||||||
window.whataverseAudio = whataverseAudio;
|
window.whataverseAudio = whataverseAudio;
|
||||||
window.engineTransitions = engineTransitions;
|
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);
|
const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport);
|
||||||
window.observationEngine = observationEngine;
|
window.observationEngine = observationEngine;
|
||||||
|
|
||||||
@@ -97,34 +113,55 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const timerDisplay = document.getElementById('timer-display');
|
const timerDisplay = document.getElementById('timer-display');
|
||||||
const timerButtons = document.querySelectorAll('.btn-timer');
|
const timerButtons = document.querySelectorAll('.btn-timer');
|
||||||
|
|
||||||
// XZBT Generative Experience subsystem. Deterministic engine remains authoritative.
|
// XZBT Generative Experience subsystem. Deterministic engine remains
|
||||||
const generativeExperience = new XZBTGenerativeExperience({
|
// authoritative. Step 6.7B: console-only -- WebLLM/Kokoro must never be
|
||||||
getUniverse: () => UniverseRegistry[activeUniverseId],
|
// constructed or prepared on the Observation surface (highest-risk guard
|
||||||
getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null,
|
// in this design; see the isConsoleMode guard on prepareExperience() in
|
||||||
isPlaying: () => isPlaying,
|
// enterObservation() below).
|
||||||
isObservation: () => observationActive,
|
let generativeExperience = null;
|
||||||
audioManager,
|
if (isConsoleMode) {
|
||||||
onGenerated: (line, state) => {
|
generativeExperience = new XZBTGenerativeExperience({
|
||||||
const ticker = document.getElementById('observation-ticker-text');
|
getUniverse: () => UniverseRegistry[activeUniverseId],
|
||||||
if (ticker && observationActive) ticker.textContent = line.toUpperCase();
|
getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null,
|
||||||
window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } }));
|
isPlaying: () => isPlaying,
|
||||||
}
|
isObservation: () => observationActive,
|
||||||
});
|
audioManager,
|
||||||
generativeExperience.bindUI();
|
onGenerated: (line, state) => {
|
||||||
window.generativeExperience = generativeExperience;
|
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
|
// Forward-declare absolute state setters & helpers for Contract Adapter
|
||||||
let setMasterVolumeDirect, setPlayingDirect, setMutedDirect, setFncEnabledDirect;
|
let setMasterVolumeDirect, setPlayingDirect, setMutedDirect, setFncEnabledDirect;
|
||||||
let setObservationDirect, setViewportFrameDirect, setAlertActiveDirect;
|
let setObservationDirect, setViewportFrameDirect, setAlertActiveDirect;
|
||||||
let setUniverseDirect, selectPresetDirect, getContractStateSnapshot;
|
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
|
// Semantic control bus & XZBT Contract Adapter. Step 6.7B: constructed
|
||||||
const controlBus = new XZBTControlBus();
|
// only in console mode -- see the isConsoleMode guard closing after
|
||||||
|
// generativeExperience.onSpeechStateChanged below.
|
||||||
|
if (isConsoleMode) {
|
||||||
|
controlBus = new XZBTControlBus();
|
||||||
window.xzbtControlBus = controlBus;
|
window.xzbtControlBus = controlBus;
|
||||||
|
|
||||||
const contractAdapter = new XZBTContractAdapter({
|
contractAdapter = new XZBTContractAdapter({
|
||||||
product: 'SciFi-XZBT',
|
product: 'SciFi-XZBT',
|
||||||
version: '5.2.0',
|
version: '5.3.0',
|
||||||
|
surfaces: XZBTSurfaceMode.SURFACES(xzbtInstanceId),
|
||||||
|
instanceId: xzbtInstanceId,
|
||||||
bindings: {
|
bindings: {
|
||||||
getUniverse: () => activeUniverseId,
|
getUniverse: () => activeUniverseId,
|
||||||
getPreset: () => activePresetId,
|
getPreset: () => activePresetId,
|
||||||
@@ -203,7 +240,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
'fnc.enabled': (v, src) => setFncEnabledDirect(v, src),
|
'fnc.enabled': (v, src) => setFncEnabledDirect(v, src),
|
||||||
'view.observation': (v, src) => setObservationDirect(v, src),
|
'view.observation': (v, src) => setObservationDirect(v, src),
|
||||||
'view.viewport-frame': (v, src) => setViewportFrameDirect(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: {
|
invokers: {
|
||||||
'speech.say': async args => {
|
'speech.say': async args => {
|
||||||
@@ -252,43 +303,130 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.xzbtContractAdapter = contractAdapter;
|
window.xzbtContractAdapter = contractAdapter;
|
||||||
fncSystem.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.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui');
|
||||||
generativeExperience.onInvoke = id => contractAdapter.invokeAction(id, {}, 'ui');
|
generativeExperience.onInvoke = id => contractAdapter?.invokeAction(id, {}, 'ui');
|
||||||
generativeExperience.onSpeechStateChanged = state => contractAdapter.updateCapability('speech', state);
|
generativeExperience.onSpeechStateChanged = state => contractAdapter?.updateCapability('speech', state);
|
||||||
observationEngine.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
|
|
||||||
|
|
||||||
|
// 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
|
// Intercept XZBTControlBus calls to route through contractAdapter
|
||||||
const origBusSet = controlBus.set.bind(controlBus);
|
const origBusSet = controlBus.set.bind(controlBus);
|
||||||
const origBusTrigger = controlBus.trigger.bind(controlBus);
|
const origBusTrigger = controlBus.trigger.bind(controlBus);
|
||||||
controlBus.set = function (id, value, source = 'external') {
|
controlBus.set = function (id, value, source = 'external') {
|
||||||
if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) {
|
if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) {
|
||||||
const canonicalId = contractAdapter.legacyIdMap.get(id) || id;
|
const canonicalId = contractAdapter?.legacyIdMap.get(id) || id;
|
||||||
const target = contractAdapter.targets.get(canonicalId);
|
const target = contractAdapter?.targets.get(canonicalId);
|
||||||
let normVal = value;
|
let normVal = value;
|
||||||
if (target && target.kind === 'range') {
|
if (target && target.kind === 'range') {
|
||||||
if (contractAdapter.legacyIdMap.has(id)) {
|
if (contractAdapter?.legacyIdMap.has(id)) {
|
||||||
const legacy = controlBus.targets.get(id);
|
const legacy = controlBus.targets.get(id);
|
||||||
if (legacy?.step) normVal = Math.round(Number(value) / legacy.step) * legacy.step;
|
if (legacy?.step) normVal = Math.round(Number(value) / legacy.step) * legacy.step;
|
||||||
if (canonicalId === 'view.activity') normVal = Number(normVal) / 100;
|
if (canonicalId === 'view.activity') normVal = Number(normVal) / 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contractAdapter.applyMutation(canonicalId, normVal, source);
|
contractAdapter?.applyMutation(canonicalId, normVal, source);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return origBusSet(id, value, source);
|
return origBusSet(id, value, source);
|
||||||
};
|
};
|
||||||
controlBus.trigger = function (id, source = 'external') {
|
controlBus.trigger = function (id, source = 'external') {
|
||||||
if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) {
|
if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) {
|
||||||
const canonicalId = contractAdapter.legacyIdMap.get(id) || id;
|
const canonicalId = contractAdapter?.legacyIdMap.get(id) || id;
|
||||||
const target = contractAdapter.targets.get(canonicalId);
|
const target = contractAdapter?.targets.get(canonicalId);
|
||||||
if (target && target.kind === 'state') {
|
if (target && target.kind === 'state') {
|
||||||
// Toggle state if boolean
|
// Toggle state if boolean
|
||||||
const curr = contractAdapter.getContractState()[canonicalId];
|
const curr = contractAdapter?.getContractState()[canonicalId];
|
||||||
contractAdapter.applyMutation(canonicalId, !curr, source);
|
contractAdapter?.applyMutation(canonicalId, !curr, source);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
contractAdapter.invokeAction(canonicalId, {}, source);
|
contractAdapter?.invokeAction(canonicalId, {}, source);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return origBusTrigger(id, source);
|
return origBusTrigger(id, source);
|
||||||
@@ -331,6 +469,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
controlBus.register('red-alert', { label: 'Red Alert Toggle', type: 'action' });
|
controlBus.register('red-alert', { label: 'Red Alert Toggle', type: 'action' });
|
||||||
controlBus.register('generate-announcement', { label: 'Generate AI Announcement', type: 'action' });
|
controlBus.register('generate-announcement', { label: 'Generate AI Announcement', type: 'action' });
|
||||||
controlBus.register('mute', { label: 'Master Mute', 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.
|
// 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.innerHTML = `<span class="u-icon">${u.icon}</span> <span>${u.name}</span>`;
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
contractAdapter.applyMutation('universe.selected', u.id, 'ui');
|
contractAdapter?.applyMutation('universe.selected', u.id, 'ui');
|
||||||
});
|
});
|
||||||
universeDropdown.appendChild(btn);
|
universeDropdown.appendChild(btn);
|
||||||
});
|
});
|
||||||
@@ -399,6 +538,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.body.className = universe.themeClass;
|
document.body.className = universe.themeClass;
|
||||||
// Assigning className wholesale drops the TV presentation classes, so re-apply them.
|
// Assigning className wholesale drops the TV presentation classes, so re-apply them.
|
||||||
applyTV();
|
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;
|
headerMatrixLabel.textContent = universe.headerTitle;
|
||||||
|
|
||||||
// Custom titles per universe
|
// Custom titles per universe
|
||||||
@@ -472,7 +613,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
soundboardTitle.textContent = conf.soundboard;
|
soundboardTitle.textContent = conf.soundboard;
|
||||||
|
|
||||||
// Configure Visualizer mode
|
// Configure Visualizer mode
|
||||||
visualizer.setMode(universe.visualizer || 'warp-core');
|
visualizer?.setMode(universe.visualizer || 'warp-core');
|
||||||
|
|
||||||
// Render Event Buttons & Soundboard
|
// Render Event Buttons & Soundboard
|
||||||
renderUniverseEvents(universe);
|
renderUniverseEvents(universe);
|
||||||
@@ -509,10 +650,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
async function invokeConsoleEvent(evt, btn, source) {
|
async function invokeConsoleEvent(evt, btn, source) {
|
||||||
if (evt.type === 'alert-red' || evt.type === 'alert-yellow') {
|
if (evt.type === 'alert-red' || evt.type === 'alert-yellow') {
|
||||||
const value = evt.type === 'alert-red' ? 'red' : '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));
|
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);
|
if (canonical) return contractAdapter?.invokeAction(canonical.id, {}, source);
|
||||||
await audioManager.resume();
|
await audioManager.resume();
|
||||||
handleUniverseEvent(evt.type, btn);
|
handleUniverseEvent(evt.type, btn);
|
||||||
}
|
}
|
||||||
@@ -531,7 +672,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
btn.addEventListener('click', async () => {
|
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 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-/, '')}`;
|
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();
|
await audioManager.resume();
|
||||||
handleSoundboardTrigger(key.id);
|
handleSoundboardTrigger(key.id);
|
||||||
});
|
});
|
||||||
@@ -553,7 +694,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
contractAdapter.applyMutation('preset.selected', preset.id, 'ui');
|
contractAdapter?.applyMutation('preset.selected', preset.id, 'ui');
|
||||||
});
|
});
|
||||||
|
|
||||||
presetContainer.appendChild(btn);
|
presetContainer.appendChild(btn);
|
||||||
@@ -763,7 +904,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
btnPlay.addEventListener('click', () => {
|
btnPlay.addEventListener('click', () => {
|
||||||
contractAdapter.applyMutation('transport.playing', !isPlaying, 'ui');
|
contractAdapter?.applyMutation('transport.playing', !isPlaying, 'ui');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Master Volume Control System
|
// 6. Master Volume Control System
|
||||||
@@ -800,65 +941,65 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setMasterVolume(val) {
|
function setMasterVolume(val) {
|
||||||
contractAdapter.applyMutation('mix.master', val, 'ui');
|
contractAdapter?.applyMutation('mix.master', val, 'ui');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMute() {
|
function toggleMute() {
|
||||||
contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'ui');
|
contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'ui');
|
||||||
}
|
}
|
||||||
|
|
||||||
sliderMasterVol.addEventListener('input', (e) => {
|
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);
|
btnMasterMute.addEventListener('click', toggleMute);
|
||||||
btnHeaderMute.addEventListener('click', toggleMute);
|
btnHeaderMute.addEventListener('click', toggleMute);
|
||||||
btnVolDown.addEventListener('click', () => {
|
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', () => {
|
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 => {
|
volStepButtons.forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
||||||
contractAdapter.applyMutation('mix.master', stepVal, 'ui');
|
contractAdapter?.applyMutation('mix.master', stepVal, 'ui');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Channel Synthesizer Sliders
|
// 7. Channel Synthesizer Sliders
|
||||||
sliderHullVol.addEventListener('input', (e) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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
|
// 8. Universe Event Handlers
|
||||||
@@ -872,7 +1013,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
case 'warp':
|
case 'warp':
|
||||||
case 'quantum':
|
case 'quantum':
|
||||||
alerts.synthesizeWarpJump();
|
alerts.synthesizeWarpJump();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'alert-red':
|
case 'alert-red':
|
||||||
case 'action-stations':
|
case 'action-stations':
|
||||||
@@ -902,11 +1043,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
break;
|
break;
|
||||||
case 'demat':
|
case 'demat':
|
||||||
whataverseAudio.synthesizeDematCycle(4);
|
whataverseAudio.synthesizeDematCycle(4);
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'vortex':
|
case 'vortex':
|
||||||
whataverseAudio.synthesizeDematCycle(1);
|
whataverseAudio.synthesizeDematCycle(1);
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'cloister':
|
case 'cloister':
|
||||||
if (whataverseAudio.activeCloister) {
|
if (whataverseAudio.activeCloister) {
|
||||||
@@ -923,7 +1064,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
case 'epstein':
|
case 'epstein':
|
||||||
case 'subdrive':
|
case 'subdrive':
|
||||||
expandedAudio.synthesizeEpsteinBurn();
|
expandedAudio.synthesizeEpsteinBurn();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'purge':
|
case 'purge':
|
||||||
case 'decompress':
|
case 'decompress':
|
||||||
@@ -936,11 +1077,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
case 'starburst':
|
case 'starburst':
|
||||||
case 'biodefense':
|
case 'biodefense':
|
||||||
expandedAudio.synthesizeStarburst();
|
expandedAudio.synthesizeStarburst();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'neural':
|
case 'neural':
|
||||||
expandedAudio.synthesizeNeuralBondSwell();
|
expandedAudio.synthesizeNeuralBondSwell();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'centrifuge':
|
case 'centrifuge':
|
||||||
case 'astrogator':
|
case 'astrogator':
|
||||||
@@ -951,7 +1092,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
break;
|
break;
|
||||||
case 'ftl-jump':
|
case 'ftl-jump':
|
||||||
expandedAudio.synthesizeFtlJump();
|
expandedAudio.synthesizeFtlJump();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'flak':
|
case 'flak':
|
||||||
expandedAudio.synthesizeFlakBarrageBurst();
|
expandedAudio.synthesizeFlakBarrageBurst();
|
||||||
@@ -960,29 +1101,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
case 'gravity':
|
case 'gravity':
|
||||||
case 'rotation':
|
case 'rotation':
|
||||||
expandedAudio.synthesizeSingularityEngage();
|
expandedAudio.synthesizeSingularityEngage();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'solar':
|
case 'solar':
|
||||||
expandedAudio.synthesizeSolarRoar();
|
expandedAudio.synthesizeSolarRoar();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'afterburner':
|
case 'afterburner':
|
||||||
expandedAudio.synthesizeAfterburner();
|
expandedAudio.synthesizeAfterburner();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'ludicrous':
|
case 'ludicrous':
|
||||||
expandedAudio.synthesizeLudicrousSpeed();
|
expandedAudio.synthesizeLudicrousSpeed();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
case 'improbability':
|
case 'improbability':
|
||||||
expandedAudio.synthesizeImprobabilityFlip();
|
expandedAudio.synthesizeImprobabilityFlip();
|
||||||
visualizer.spawnWarpPulses();
|
visualizer?.spawnWarpPulses();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
telemetry.synthesizeLCARDSingleChirp();
|
telemetry.synthesizeLCARDSingleChirp();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
generativeExperience.describeExistingEvent(type);
|
generativeExperience?.describeExistingEvent(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSoundboardTrigger(id) {
|
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);
|
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') {
|
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
|
// Telemetry is a synchronization pulse, but the slider controls whether
|
||||||
// that pulse becomes visible. At maximum every telemetry pulse produces
|
// that pulse becomes visible. At maximum every telemetry pulse produces
|
||||||
@@ -2713,7 +2868,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearTimeout(observationAmbientTimer);
|
clearTimeout(observationAmbientTimer);
|
||||||
observationAmbientTimer = null;
|
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.
|
// Quiet = occasional subtle life. Active = a busy generative display.
|
||||||
const slow = 12500;
|
const slow = 12500;
|
||||||
@@ -2798,7 +2958,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateObservationHudMode();
|
updateObservationHudMode();
|
||||||
|
|
||||||
sliderObservationActivity.addEventListener('input', () => {
|
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) => {
|
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() {
|
function refreshObservation() {
|
||||||
const universe = UniverseRegistry[activeUniverseId];
|
const universe = UniverseRegistry[activeUniverseId];
|
||||||
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
|
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
|
||||||
@@ -4442,18 +4644,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function enterObservation() {
|
function enterObservation() {
|
||||||
if (observationActive) return;
|
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;
|
observationActive = true;
|
||||||
observationOverlay.classList.add('active');
|
observationOverlay.classList.add('active');
|
||||||
observationOverlay.setAttribute('aria-hidden', 'false');
|
observationOverlay.setAttribute('aria-hidden', 'false');
|
||||||
refreshObservation();
|
refreshObservation();
|
||||||
resetObservationHudFade();
|
resetObservationHudFade();
|
||||||
resetObservationReturnFade();
|
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.
|
// Give the display an immediate but restrained sign of life.
|
||||||
if (observationActivity > .08) {
|
if (observationActivity > .08) {
|
||||||
window.setTimeout(() => triggerObservationActivity('ambient'), 700);
|
window.setTimeout(() => triggerObservationActivity('ambient'), 700);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4502,7 +4715,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
btnObservation.addEventListener('click', (e) => {
|
btnObservation.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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
|
// 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;
|
if (e.target.closest('#observation-control-dock')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
contractAdapter.applyMutation('view.observation', false, 'ui');
|
contractAdapter?.applyMutation('view.observation', false, 'ui');
|
||||||
});
|
});
|
||||||
|
|
||||||
const observationReturnPill = document.getElementById('observation-return-pill');
|
const observationReturnPill = document.getElementById('observation-return-pill');
|
||||||
@@ -4518,19 +4731,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
observationReturnPill.addEventListener('click', (e) => {
|
observationReturnPill.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||||
|
|
||||||
if (e.code === 'Space') {
|
if (e.code === 'Space') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
contractAdapter.applyMutation('transport.playing', !isPlaying, 'hotkey');
|
contractAdapter?.applyMutation('transport.playing', !isPlaying, 'hotkey');
|
||||||
} else if (e.code === 'KeyM') {
|
} else if (e.code === 'KeyM') {
|
||||||
contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey');
|
contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey');
|
||||||
} else if (e.code === 'KeyW') {
|
} else if (e.code === 'KeyW') {
|
||||||
if (observationActive && observationEngine) {
|
if (observationActive && observationEngine) {
|
||||||
observationEngine.toggleWarpFlight();
|
observationEngine.toggleWarpFlight();
|
||||||
@@ -4541,7 +4756,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (primaryEvent) invokeConsoleEvent(primaryEvent, primaryActionBtn, 'hotkey');
|
if (primaryEvent) invokeConsoleEvent(primaryEvent, primaryActionBtn, 'hotkey');
|
||||||
} else if (e.code === 'KeyF') {
|
} else if (e.code === 'KeyF') {
|
||||||
if (observationActive && observationEngine) {
|
if (observationActive && observationEngine) {
|
||||||
contractAdapter.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey');
|
contractAdapter?.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (e.code === 'KeyP') {
|
} else if (e.code === 'KeyP') {
|
||||||
@@ -4552,7 +4767,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
} else if (e.code === 'KeyR') {
|
} else if (e.code === 'KeyR') {
|
||||||
if (observationActive && observationEngine) {
|
if (observationActive && observationEngine) {
|
||||||
const nextAlert = alerts.activeAlert === 'red' ? 'none' : 'red';
|
const nextAlert = alerts.activeAlert === 'red' ? 'none' : 'red';
|
||||||
contractAdapter.applyMutation('alert.active', nextAlert, 'hotkey');
|
contractAdapter?.applyMutation('alert.active', nextAlert, 'hotkey');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
|
const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
|
||||||
@@ -4561,23 +4776,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (alertEvent) invokeConsoleEvent(alertEvent, firstAlertBtn, 'hotkey');
|
if (alertEvent) invokeConsoleEvent(alertEvent, firstAlertBtn, 'hotkey');
|
||||||
} else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
|
} else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
|
||||||
e.preventDefault();
|
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') {
|
} else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
|
||||||
e.preventDefault();
|
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') {
|
} else if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
||||||
const num = parseInt(e.code.replace('Digit', ''));
|
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') {
|
} else if (e.code === 'Digit0') {
|
||||||
contractAdapter.applyMutation('mix.master', 1.0, 'hotkey');
|
contractAdapter?.applyMutation('mix.master', 1.0, 'hotkey');
|
||||||
} else if (e.code === 'Escape') {
|
} else if (e.code === 'Escape') {
|
||||||
if (observationActive) {
|
if (observationActive) {
|
||||||
contractAdapter.applyMutation('view.observation', false, 'hotkey');
|
contractAdapter?.applyMutation('view.observation', false, 'hotkey');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
contractAdapter.applyMutation('alert.active', 'none', 'hotkey');
|
contractAdapter?.applyMutation('alert.active', 'none', 'hotkey');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} // end isConsoleMode (keyboard shortcuts)
|
||||||
|
|
||||||
// --- Implementation of Absolute Setters and Contract Helpers ---
|
// --- Implementation of Absolute Setters and Contract Helpers ---
|
||||||
setMasterVolumeDirect = function (val, isMuted = false) {
|
setMasterVolumeDirect = function (val, isMuted = false) {
|
||||||
@@ -4688,6 +4904,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
'fnc.level': fncSystem ? fncSystem.level : 0.5,
|
'fnc.level': fncSystem ? fncSystem.level : 0.5,
|
||||||
'view.observation': observationActive,
|
'view.observation': observationActive,
|
||||||
'view.viewport-frame': observationEngine ? observationEngine.showViewport : true,
|
'view.viewport-frame': observationEngine ? observationEngine.showViewport : true,
|
||||||
|
'view.pillars': observationEngine ? observationEngine.showPillars : false,
|
||||||
|
'view.warp-flight': observationEngine ? observationEngine.flightMode === 'warp' : false,
|
||||||
'view.activity': observationActivity,
|
'view.activity': observationActivity,
|
||||||
'alert.active': alertStatus,
|
'alert.active': alertStatus,
|
||||||
'universe.selected': activeUniverseId,
|
'universe.selected': activeUniverseId,
|
||||||
@@ -4699,5 +4917,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initUniverseDropdown();
|
initUniverseDropdown();
|
||||||
setUniverse('starfleet');
|
setUniverse('starfleet');
|
||||||
updateMasterVolumeUI(0.75, false);
|
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:
|
* Implements the normalized contract interface for SciFi-XZBT:
|
||||||
* - Canonical external target catalog & descriptors
|
* - Canonical external target catalog & descriptors
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
class XZBTContractAdapter {
|
class XZBTContractAdapter {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.product = options.product || 'SciFi-XZBT';
|
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.build = options.build || 'production';
|
||||||
this.contractMajor = 5;
|
this.contractMajor = 5;
|
||||||
this.contractMinor = 2;
|
this.contractMinor = 3;
|
||||||
|
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
this.sessionActive = false;
|
this.sessionActive = false;
|
||||||
@@ -28,6 +28,17 @@
|
|||||||
this.operationQueue = Promise.resolve();
|
this.operationQueue = Promise.resolve();
|
||||||
this.eventSink = null;
|
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
|
// Transport hooks
|
||||||
this.onMessage = options.onMessage || (() => {});
|
this.onMessage = options.onMessage || (() => {});
|
||||||
this.hostOrigin = options.hostOrigin || null; // optional origin filter
|
this.hostOrigin = options.hostOrigin || null; // optional origin filter
|
||||||
@@ -355,6 +366,31 @@
|
|||||||
requires: ['observation']
|
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({
|
reg({
|
||||||
id: 'alert.active',
|
id: 'alert.active',
|
||||||
label: 'Active Tactical Alert Status',
|
label: 'Active Tactical Alert Status',
|
||||||
@@ -416,9 +452,9 @@
|
|||||||
restorable: false,
|
restorable: false,
|
||||||
category: 'speech',
|
category: 'speech',
|
||||||
requires: ['speech'],
|
requires: ['speech'],
|
||||||
args: {
|
arguments: [
|
||||||
text: { type: 'string', required: true, maxLength: 2000 }
|
{ name: 'text', type: 'string', required: true, maxLength: 2000 }
|
||||||
}
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
reg({
|
reg({
|
||||||
@@ -442,9 +478,9 @@
|
|||||||
restorable: false,
|
restorable: false,
|
||||||
category: 'display',
|
category: 'display',
|
||||||
requires: ['display-text'],
|
requires: ['display-text'],
|
||||||
args: {
|
arguments: [
|
||||||
text: { type: 'string', required: true, maxLength: 512 }
|
{ name: 'text', type: 'string', required: true, maxLength: 512 }
|
||||||
}
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 4. Canonical Universe Event Impulses ---
|
// --- 4. Canonical Universe Event Impulses ---
|
||||||
@@ -555,7 +591,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ignore non-XZBT messages
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) 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]
|
stateRevision: this.stateRevision, target: target.id, value: after[target.id]
|
||||||
}, correlationId, source);
|
}, 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,11 +800,13 @@
|
|||||||
|
|
||||||
// Validate args schema & text length limits if defined
|
// Validate args schema & text length limits if defined
|
||||||
const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {};
|
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' };
|
return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
|
||||||
}
|
}
|
||||||
if (target.args) {
|
if (argumentSpecs.length) {
|
||||||
for (const [argKey, argSpec] of Object.entries(target.args)) {
|
for (const argSpec of argumentSpecs) {
|
||||||
|
const argKey = argSpec.name;
|
||||||
if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
|
if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
|
||||||
return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
|
return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
|
||||||
}
|
}
|
||||||
@@ -823,6 +870,11 @@
|
|||||||
args: sanitizedArgs
|
args: sanitizedArgs
|
||||||
}, correlationId, source);
|
}, correlationId, source);
|
||||||
|
|
||||||
|
// Session-independent mirror feed (Step 6.7B); see _commitChanges.
|
||||||
|
if (this.onLocalAction) {
|
||||||
|
this.onLocalAction(canonicalId, sanitizedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,7 +888,7 @@
|
|||||||
|
|
||||||
// Public API: Protocol Description
|
// Public API: Protocol Description
|
||||||
describe() {
|
describe() {
|
||||||
return {
|
const description = {
|
||||||
exhibit: {
|
exhibit: {
|
||||||
product: this.product,
|
product: this.product,
|
||||||
version: this.version,
|
version: this.version,
|
||||||
@@ -851,6 +903,10 @@
|
|||||||
capabilities: Array.from(this.capabilities.values()),
|
capabilities: Array.from(this.capabilities.values()),
|
||||||
targets: Array.from(this.targets.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
|
// Update Capability state and emit capability.changed
|
||||||
|
|||||||
@@ -204,7 +204,12 @@ class ObservationEngine {
|
|||||||
if (this.btnWarp) {
|
if (this.btnWarp) {
|
||||||
this.btnWarp.addEventListener('click', (e) => {
|
this.btnWarp.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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) {
|
if (this.btnPillars) {
|
||||||
this.btnPillars.addEventListener('click', (e) => {
|
this.btnPillars.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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
|
* WHY THIS FILE IS GENERIC
|
||||||
* ------------------------
|
* ------------------------
|
||||||
@@ -26,25 +26,33 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var CONTRACT_MAJOR = 5;
|
var CONTRACT_MAJOR = 5;
|
||||||
var CONTRACT_MINOR = 2;
|
var CONTRACT_MINOR = 3;
|
||||||
var XZBT_VERSION = '5.2';
|
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`,
|
* `surfaces` remains OPTIONAL per-exhibit: an exhibit that does not supply
|
||||||
* or `surfaces` to ContractCore gets byte-identical behavior to before this
|
* a SurfaceCatalog simply gets no `surfaces` key in `describe()` at all
|
||||||
* addition: the defaults below equal the pre-5.3 constants exactly, and
|
* (Contract 5.3 §7). This file remains domain-free; it knows the *shape*
|
||||||
* `describe()` omits the `surfaces` key entirely unless a SurfaceCatalog
|
* of Contract 5.3 Section 31, not any exhibit's surface content.
|
||||||
* was supplied. 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. */
|
* boundary; a source supplied by a caller is never trusted. */
|
||||||
var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
|
var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
|
||||||
|
|
||||||
/* Contract 5.2 §24. */
|
/* Contract §24. */
|
||||||
var ERROR_CODES = [
|
var ERROR_CODES = [
|
||||||
'UNSUPPORTED_VERSION',
|
'UNSUPPORTED_VERSION',
|
||||||
'INVALID_MESSAGE',
|
'INVALID_MESSAGE',
|
||||||
@@ -59,10 +67,10 @@
|
|||||||
'INTERNAL_ERROR'
|
'INTERNAL_ERROR'
|
||||||
];
|
];
|
||||||
|
|
||||||
/* Contract 5.2 §17. */
|
/* Contract §17. */
|
||||||
var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
|
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. */
|
* new canonical event types. */
|
||||||
var EVENT_TYPES = [
|
var EVENT_TYPES = [
|
||||||
'state.changed',
|
'state.changed',
|
||||||
@@ -746,14 +754,13 @@
|
|||||||
if (!isPlainObject(message)) {
|
if (!isPlainObject(message)) {
|
||||||
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
|
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
|
||||||
}
|
}
|
||||||
if (message.xzbt !== this.xzbtVersion) {
|
/* `xzbt` is advisory metadata, not a compatibility gate (Contract §6.5):
|
||||||
return this._errorEnvelope(
|
* a 5.3-aware host must still interoperate with a 5.2 exhibit and vice
|
||||||
message.requestId || null,
|
* versa. Real compatibility is negotiated by handleHello below via
|
||||||
message.sessionId || null,
|
* `supportedContractMajors`/`contract.major`/`minor`; rejecting here on
|
||||||
'UNSUPPORTED_VERSION',
|
* an exact string mismatch pre-empted that negotiation and made it
|
||||||
'This exhibit implements contract ' + this.xzbtVersion + '.'
|
* 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') {
|
if (typeof message.type !== 'string') {
|
||||||
return this._errorEnvelope(
|
return this._errorEnvelope(
|
||||||
message.requestId || null,
|
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
|
* 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
|
* deliberately the thinnest file in the project. It knows how to move
|
||||||
@@ -61,7 +61,15 @@
|
|||||||
|
|
||||||
var message = event.data;
|
var message = event.data;
|
||||||
if (!message || typeof message !== 'object') return;
|
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. */
|
/* Source is assigned here, at the trusted receiving boundary. */
|
||||||
var response = this.core.handleRequest(message, 'host');
|
var response = this.core.handleRequest(message, 'host');
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ test('stalled document load ends with recoverable error', async t => {
|
|||||||
assert.equal(errors[0].code, 'LOAD_TIMEOUT');
|
assert.equal(errors[0].code, 'LOAD_TIMEOUT');
|
||||||
connection.reconnect(); assert.equal(frames.length, 2);
|
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', () => {
|
test('optional descriptor metadata is permissive when omitted and typed when supplied', () => {
|
||||||
const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [],
|
const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [],
|
||||||
targets: [{ id: 'sample.enabled', kind: 'state', readable: true, writable: true, valueType: 'boolean', requires: [] }] };
|
targets: [{ id: 'sample.enabled', kind: 'state', readable: true, writable: true, valueType: 'boolean', requires: [] }] };
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ function makeContext() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeOwner() {
|
function makeOwner(options) {
|
||||||
const ctx = makeContext();
|
const ctx = makeContext();
|
||||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||||
for (const file of ['exhibit.js', 'contract-adapter.js', 'surface-bus.js']) {
|
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 gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
|
||||||
const core = ctx.window.MuseumGalleryContract.create(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 };
|
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());
|
const description = plain(core.describe());
|
||||||
assert.equal('surfaces' in description, false);
|
assert.equal('surfaces' in description, false);
|
||||||
assert.equal(description.contract.major, 5);
|
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 () => {
|
test('Detach does not mutate state, and a later reattach still observes it correctly', async () => {
|
||||||
const { core, bus } = makeOwner();
|
const { core, bus } = makeOwner();
|
||||||
let first, second;
|
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);
|
assert.equal(ctx.window.MuseumGalleryContract, undefined);
|
||||||
closeAll({ link });
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Phase 6.3 — Surface catalog validation and lifecycle tests.
|
* Phase 6.3 — Surface catalog validation and lifecycle tests.
|
||||||
*
|
*
|
||||||
* Covers Contract 5.3 §§31.2-31.5 normative validation order,
|
* Covers Contract 5.3 §§31.2-31.5 normative validation order, host state
|
||||||
* host state lifecycle, registry refresh behavior, and 5.2 backward
|
* lifecycle, registry refresh behavior, and the host's protocol-level
|
||||||
* compatibility. No exhibit-specific IDs appear in this file.
|
* 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 test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|||||||
Reference in New Issue
Block a user