generated from Labyricorn/labyricorn-project-template
Step 6.2 Complete — Museum Gallery reference exhibit and Contract 5.3 spec
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
--- /mnt/user-data/uploads/XZBT-NGN/docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md 2026-09-14 20:04:53.376627937 +0000
|
||||
+++ /home/claude/XZBT-Exhibit-Contract-Specification-v5.3.md 2026-09-14 20:19:48.228607469 +0000
|
||||
@@ -1,10 +1,14 @@
|
||||
# XZBT Exhibit Contract Specification
|
||||
-## Version 5.2
|
||||
+## Version 5.3
|
||||
|
||||
-**Status:** Proposed normative specification
|
||||
-**Document version:** 5.2
|
||||
-**Contract family:** XZBT Exhibit Contract
|
||||
-**Compatibility major:** 5
|
||||
+**Status:** Proposed normative specification
|
||||
+**Document version:** 5.3
|
||||
+**Contract family:** XZBT Exhibit Contract
|
||||
+**Compatibility major:** 5
|
||||
+**Supersedes:** Version 5.2, additively. No normative text in Sections 1–30
|
||||
+below has been altered from 5.2 except the two explicitly-marked additions in
|
||||
+Section 7 (the `surfaces` field reference) and Section 28 (subsection 28.8).
|
||||
+See Section 39 for the full changelog.
|
||||
**Primary consumers:** XZBT-compatible exhibits and XZBT-NGN Exhibit Engine
|
||||
|
||||
---
|
||||
@@ -111,7 +115,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "hello",
|
||||
"requestId": "req-001",
|
||||
"host": {
|
||||
@@ -126,7 +130,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "hello.result",
|
||||
"requestId": "req-001",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -137,13 +141,15 @@
|
||||
},
|
||||
"contract": {
|
||||
"major": 5,
|
||||
- "minor": 2
|
||||
+ "minor": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no compatible major version exists, the exhibit MUST respond with an `error` carrying code `UNSUPPORTED_VERSION`, MUST echo the `requestId` when recoverable, and MUST NOT create a session.
|
||||
|
||||
+A 5.2-only exhibit reporting `{"major": 5, "minor": 2}` remains fully compatible with a 5.3-aware host under the ordinary contract-major compatibility rule (Section 28.1); nothing in this handshake requires either party to implement 5.3-specific behavior.
|
||||
+
|
||||
---
|
||||
|
||||
## 6. Message Envelope
|
||||
@@ -154,7 +160,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke",
|
||||
"requestId": "req-1042",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -169,7 +175,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke.result",
|
||||
"requestId": "req-1042",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -181,7 +187,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "error",
|
||||
"requestId": "req-1042",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -219,7 +225,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "describe",
|
||||
"requestId": "req-010",
|
||||
"sessionId": "sess-7f2a"
|
||||
@@ -230,7 +236,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "describe.result",
|
||||
"requestId": "req-010",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -241,12 +247,13 @@
|
||||
},
|
||||
"contract": {
|
||||
"major": 5,
|
||||
- "minor": 2
|
||||
+ "minor": 3
|
||||
},
|
||||
"registryRevision": 1,
|
||||
"stateRevision": 27,
|
||||
"capabilities": [],
|
||||
- "targets": []
|
||||
+ "targets": [],
|
||||
+ "surfaces": []
|
||||
}
|
||||
```
|
||||
|
||||
@@ -256,6 +263,14 @@
|
||||
|
||||
NGN MUST discover targets instead of assuming that an exhibit exposes a fixed science-fiction vocabulary.
|
||||
|
||||
+**`surfaces` (introduced in Contract 5.3).** An OPTIONAL array of presentation
|
||||
+surface descriptors, normatively defined in Section 31. Its absence, or an
|
||||
+empty array, both mean the exhibit has not adopted multi-surface presentation
|
||||
+and is functionally equivalent to a Contract 5.2 `describe.result`, which
|
||||
+never contained this field. A 5.3-aware host MUST treat a `describe.result`
|
||||
+lacking `surfaces` identically to one where `surfaces` is present and empty
|
||||
+(Section 31.5).
|
||||
+
|
||||
---
|
||||
|
||||
## 8. Canonical Target IDs
|
||||
@@ -294,6 +309,9 @@
|
||||
[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+
|
||||
```
|
||||
|
||||
+This grammar also governs presentation surface identifiers (Section 31.2);
|
||||
+no separate identifier grammar is defined for surfaces.
|
||||
+
|
||||
### 8.2 Stability
|
||||
|
||||
Once published as part of an exhibit's external contract, a canonical target ID becomes part of that exhibit's compatibility surface.
|
||||
@@ -405,6 +423,11 @@
|
||||
|
||||
`kind` is authoritative for invokability. No separate `invokable` field is defined.
|
||||
|
||||
+Presentation surfaces (Section 31) are a structurally distinct descriptor
|
||||
+family from targets and are never expressed using any target `kind`,
|
||||
+including `impulse`. A host MUST NOT infer surface existence from any target
|
||||
+descriptor.
|
||||
+
|
||||
### 9.5 Impulse arguments (authoritative Contract 5.2 clarification)
|
||||
|
||||
Impulse descriptors use `arguments`, an array of argument descriptors. This
|
||||
@@ -489,7 +512,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke",
|
||||
"requestId": "req-200",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -514,7 +537,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "set",
|
||||
"requestId": "req-201",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -537,7 +560,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "state.get",
|
||||
"requestId": "req-300",
|
||||
"sessionId": "sess-7f2a"
|
||||
@@ -548,7 +571,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "state.result",
|
||||
"requestId": "req-300",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -568,6 +591,11 @@
|
||||
|
||||
A state snapshot MUST NOT imply that every internal exhibit variable is externally exposed.
|
||||
|
||||
+State exposed through `values` is scoped to the one logical exhibit instance
|
||||
+and its one session, regardless of how many presentation surfaces (Section
|
||||
+31) are currently open. `state.get` MUST NOT be parameterized by surface, and
|
||||
+no surface-specific state view is defined by this contract.
|
||||
+
|
||||
---
|
||||
|
||||
## 14. State Revision and Mutation Transactions
|
||||
@@ -588,6 +616,10 @@
|
||||
|
||||
All contract-visible state changes committed by one transaction share one resulting `stateRevision`.
|
||||
|
||||
+A native interaction originating on any presentation surface (Section 31) is
|
||||
+one mutation transaction, subject to this same rule, regardless of which
|
||||
+surface it originated on.
|
||||
+
|
||||
### 14.2 Ordering
|
||||
|
||||
The exhibit MUST:
|
||||
@@ -608,6 +640,11 @@
|
||||
|
||||
The counters serve different purposes and MUST NOT be treated as interchangeable.
|
||||
|
||||
+Multi-surface presentation does not introduce a second revision or sequence
|
||||
+counter of any kind. Every presentation surface of one exhibit instance
|
||||
+observes the same `stateRevision` history and the same event `sequence`
|
||||
+stream defined here.
|
||||
+
|
||||
---
|
||||
|
||||
## 15. Source and Origin
|
||||
@@ -632,6 +669,12 @@
|
||||
|
||||
This distinction is required for recording and feedback-loop prevention.
|
||||
|
||||
+An interaction originating on a presentation surface other than the primary
|
||||
+surface (Section 31.3) uses the same `source` vocabulary as an interaction on
|
||||
+the primary surface — typically `ui`. This contract does not define a
|
||||
+per-surface source value; which surface an interaction originated on is not
|
||||
+contract-visible.
|
||||
+
|
||||
---
|
||||
|
||||
## 16. Events
|
||||
@@ -653,7 +696,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "state.changed",
|
||||
"sessionId": "sess-7f2a",
|
||||
"sequence": 144,
|
||||
@@ -669,7 +712,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "action.executed",
|
||||
"sessionId": "sess-7f2a",
|
||||
"sequence": 145,
|
||||
@@ -710,6 +753,13 @@
|
||||
|
||||
It SHOULD NOT automatically record its own `scenario` or host playback events back into the scenario being recorded unless explicitly configured.
|
||||
|
||||
+### 16.6 `registry.changed` and surfaces (Contract 5.3)
|
||||
+
|
||||
+`registry.changed` (Section 23) covers changes to `surfaces` in addition to
|
||||
+`targets` and capability metadata. A single `registryRevision` governs both;
|
||||
+this contract does not define a separate surface-registry revision. See
|
||||
+Section 31.6.
|
||||
+
|
||||
---
|
||||
|
||||
## 17. Capability Model
|
||||
@@ -751,6 +801,10 @@
|
||||
|
||||
A host MUST treat the current discovered state as authoritative.
|
||||
|
||||
+A presentation surface (Section 31) MAY declare `requires` against
|
||||
+capability IDs defined here, using identical semantics to target `requires`
|
||||
+(Section 9).
|
||||
+
|
||||
---
|
||||
|
||||
## 18. Speech
|
||||
@@ -765,7 +819,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke",
|
||||
"requestId": "req-410",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -843,6 +897,9 @@
|
||||
|
||||
Each text target SHOULD declare a maximum accepted length.
|
||||
|
||||
+This restriction applies equally to any text rendered by a presentation
|
||||
+surface (Section 31); surfaces introduce no new text-injection surface area.
|
||||
+
|
||||
---
|
||||
|
||||
## 21. Telemetry
|
||||
@@ -899,7 +956,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "set",
|
||||
"requestId": "req-520",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -914,7 +971,7 @@
|
||||
|
||||
After lease expiration, the exhibit MAY return to simulated ownership.
|
||||
|
||||
-The exact telemetry lease mechanism remains optional in 5.2.
|
||||
+The exact telemetry lease mechanism remains optional in 5.2, unchanged in 5.3.
|
||||
|
||||
A simple explicit release mechanism is also acceptable.
|
||||
|
||||
@@ -932,6 +989,10 @@
|
||||
|
||||
Registry revision SHOULD NOT change merely because a fixed target becomes contextually unavailable.
|
||||
|
||||
+As of Contract 5.3, `registryRevision` also governs the `surfaces` array
|
||||
+(Section 31.6). One counter covers both; this contract does not define an
|
||||
+independent surface-registry revision.
|
||||
+
|
||||
---
|
||||
|
||||
## 24. Error Codes
|
||||
@@ -978,6 +1039,11 @@
|
||||
|
||||
Same-origin `postMessage` transports MUST validate both `event.origin` and `event.source` against the expected host relationship.
|
||||
|
||||
+The same-origin requirement in this section extends to presentation surface
|
||||
+resolution and to any exhibit-internal attachment channel used between an
|
||||
+exhibit's own documents (Section 31.7). Neither introduces a new trust
|
||||
+boundary beyond the one already defined here.
|
||||
+
|
||||
---
|
||||
|
||||
## 26. Scenario Independence
|
||||
@@ -1056,6 +1122,16 @@
|
||||
|
||||
An exhibit may add targets without changing the contract version, provided existing target semantics remain compatible.
|
||||
|
||||
+### 28.8 Contract 5.3 (presentation surfaces)
|
||||
+
|
||||
+Contract 5.3 adds the optional `surfaces` field to `describe.result`
|
||||
+(Section 7) and the normative Presentation Surfaces model (Section 31), per
|
||||
+the rule in 28.2: this is a backward-compatible addition, not a breaking
|
||||
+change. Contract major remains 5. An exhibit reporting `{major: 5, minor: 2}`
|
||||
+is unaffected by this addition and remains fully conformant; a 5.3-aware host
|
||||
+MUST continue to interoperate with it exactly as under Contract 5.2 (Section
|
||||
+31.5).
|
||||
+
|
||||
---
|
||||
|
||||
## 29. Conformance Minimum
|
||||
@@ -1076,6 +1152,11 @@
|
||||
|
||||
Speech, telemetry, MIDI, scenarios, Web3D, and any particular exhibit namespace are optional capabilities.
|
||||
|
||||
+This conformance minimum is unchanged by Contract 5.3. Presentation surfaces
|
||||
+(Section 31) are an optional capability of the same kind: an exhibit that
|
||||
+implements none of Section 31 and never emits `surfaces` remains fully
|
||||
+conformant, at either contract minor.
|
||||
+
|
||||
---
|
||||
|
||||
## 30. Architectural Summary
|
||||
@@ -1089,3 +1170,222 @@
|
||||
**The contract defines how that capability is described, observed, and invoked.**
|
||||
|
||||
**XZBT-NGN decides how to orchestrate and integrate it.**
|
||||
+
|
||||
+Version 5.3 preserves this architecture without modification and adds exactly
|
||||
+one optional capability class — presentation surfaces (Section 31) — built
|
||||
+entirely on the existing session, state-revision, event, and registry-revision
|
||||
+mechanisms defined above.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## 31. Presentation Surfaces (introduced in Contract 5.3)
|
||||
+
|
||||
+This section is new in Contract 5.3. It formalizes the multi-surface
|
||||
+presentation model approved in XZBT-NGN's Step 6.1 architecture document
|
||||
+(`docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md`, Revision 2). Its
|
||||
+scope is narrowly the discovery and identification of presentation surfaces
|
||||
+and the contract-visible guarantees around them; it does not define display
|
||||
+transport, casting, or remote endpoints (explicitly out of scope — see
|
||||
+Section 31.9).
|
||||
+
|
||||
+### 31.1 Definition
|
||||
+
|
||||
+A **presentation surface** is a renderable, full-screen-capable visual view
|
||||
+of one logical exhibit instance, addressable by a stable identifier, that an
|
||||
+exhibit advertises as independently viewable. Every presentation surface of
|
||||
+one exhibit instance is driven by that exhibit's one authoritative
|
||||
+`stateRevision` history and one event `sequence` stream (Sections 13, 14,
|
||||
+16); this contract defines no mechanism by which two surfaces of the same
|
||||
+exhibit instance could observe divergent state.
|
||||
+
|
||||
+A presentation surface is not a target (Section 9), not a capability
|
||||
+(Section 17), and not the host's own control/administration interface. It
|
||||
+MUST NOT be represented using any target `kind`.
|
||||
+
|
||||
+### 31.2 Surface descriptor
|
||||
+
|
||||
+When an exhibit advertises presentation surfaces, each entry in the
|
||||
+`surfaces` array (Section 7) MUST be an object with the following fields:
|
||||
+
|
||||
+| Field | Requirement | Notes |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | REQUIRED string | MUST conform to the canonical target-ID grammar (Section 8.1). No separate identifier grammar is defined for surfaces. |
|
||||
+| `label` | REQUIRED string | Human-readable. |
|
||||
+| `kind` | REQUIRED, constant `"surface"` | Identifies the descriptor type. |
|
||||
+| `primary` | REQUIRED boolean | Governed by Section 31.3. |
|
||||
+| `url` | REQUIRED string | Governed by Section 31.4. |
|
||||
+
|
||||
+The following fields are OPTIONAL:
|
||||
+
|
||||
+| Field | Purpose |
|
||||
+| --- | --- |
|
||||
+| `description` | Longer human-readable text. |
|
||||
+| `role` | Advisory grouping hint (e.g. `"control"`, `"ambient"`, `"information"`). No contract behavior depends on this field's value in Contract 5.3. |
|
||||
+| `aspectRatio` | Advisory sizing hint (e.g. `"16:9"`). |
|
||||
+| `category` | Mirrors target `category` (Section 9) for consistent grouping. |
|
||||
+| `requires` | Array of capability IDs (Section 17), with identical semantics to target `requires`: the surface remains discoverable but SHOULD be presented as degraded or unavailable when a required capability is not `ready`. |
|
||||
+
|
||||
+No field of a surface descriptor MAY reference a display transport, casting
|
||||
+protocol, network endpoint, or device class (Section 31.9).
|
||||
+
|
||||
+### 31.3 The `primary` invariant
|
||||
+
|
||||
+`surfaces` (Section 7) has exactly three conformant forms:
|
||||
+
|
||||
+1. **Absent.** The exhibit does not advertise multi-surface presentation.
|
||||
+ Functionally equivalent to a Contract 5.2 `describe.result`.
|
||||
+2. **Present and empty (`[]`).** MUST be treated identically to form 1 by a
|
||||
+ conformant host.
|
||||
+3. **Present and non-empty.** The array MUST contain exactly one entry with
|
||||
+ `primary: true`. An array with zero or more than one `primary: true`
|
||||
+ entry is malformed as a whole; a host encountering this MUST fall back to
|
||||
+ treating `surfaces` as absent (form 1) and SHOULD report a diagnostic.
|
||||
+
|
||||
+The `primary` surface, when `surfaces` is present and non-empty, is the
|
||||
+surface a host with no surface-specific UI opens by default, and is the
|
||||
+surface whose standalone-use guarantee is unconditional (Section 31.8).
|
||||
+
|
||||
+### 31.4 URL resolution
|
||||
+
|
||||
+`url` MUST be one of:
|
||||
+
|
||||
+- a path relative to the exhibit's own base document location — the same
|
||||
+ base URL already used to establish the exhibit's session;
|
||||
+- such a relative path with an appended query string and/or fragment;
|
||||
+- 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 host resolving `url` MUST:
|
||||
+
|
||||
+1. resolve it against the exhibit's already-established base URL, not
|
||||
+ against the host's own administrative interface location;
|
||||
+2. reject the entry (Section 31.5) if the resolved URL is not same-origin
|
||||
+ with that base, per the same-origin requirement in Section 25;
|
||||
+3. apply the same path-containment validation the host already applies to
|
||||
+ the exhibit's primary document, if any such validation exists in that
|
||||
+ host implementation.
|
||||
+
|
||||
+Absolute, protocol-relative, or cross-origin `url` values MUST be rejected as
|
||||
+malformed individual entries; they do not invalidate the rest of the array.
|
||||
+
|
||||
+### 31.5 Validation and backward compatibility
|
||||
+
|
||||
+A host MUST validate each surface entry independently. An entry missing a
|
||||
+required field, using an invalid `id` (Section 8.1), or specifying a `url`
|
||||
+that fails Section 31.4 MUST be rejected individually; the host SHOULD skip
|
||||
+only that entry, log a diagnostic, and continue processing the remainder of
|
||||
+`surfaces`, except for the structural `primary` violation in Section 31.3,
|
||||
+which invalidates the whole array.
|
||||
+
|
||||
+A host that does not implement any Section 31 behavior MAY safely ignore the
|
||||
+`surfaces` field entirely; doing so is fully conformant with the
|
||||
+`describe.result` schema, since the field is OPTIONAL (Section 7).
|
||||
+
|
||||
+An exhibit implementing only Contract 5.2 behavior is unaffected: it never
|
||||
+emits `surfaces`, and no 5.3-only requirement applies to it.
|
||||
+
|
||||
+### 31.6 Registry governance
|
||||
+
|
||||
+`surfaces` is governed by `registryRevision` (Section 23) exactly as
|
||||
+`targets` is. There is no independent surface-registry counter. A
|
||||
+`registry.changed` event (Section 16.6) requires the host to re-run
|
||||
+`describe` and re-read both `targets` and `surfaces`.
|
||||
+
|
||||
+### 31.7 State and interaction
|
||||
+
|
||||
+All presentation surfaces of one exhibit instance share that instance's one
|
||||
+session, one `stateRevision` history, and one event `sequence` stream
|
||||
+(Sections 13, 14, 16); this contract defines no per-surface state channel
|
||||
+and no per-surface session. How an exhibit internally propagates state to
|
||||
+each surface's rendering code, and how it routes a surface-originated
|
||||
+interaction back into its own state mutation logic, is an exhibit
|
||||
+implementation detail outside this contract's normative scope — the contract
|
||||
+requires only the observable outcome: one authoritative state, and any
|
||||
+successful interaction on any surface behaves, from the contract's
|
||||
+perspective, exactly like the equivalent `set`/`invoke` (Section 14.1).
|
||||
+
|
||||
+An exhibit's internal mechanism for connecting a surface's rendering code to
|
||||
+its own state, including any same-origin channel used between the exhibit's
|
||||
+own documents, is not part of the message envelope defined in Section 6 and
|
||||
+is never observed by the host.
|
||||
+
|
||||
+### 31.8 Standalone behavior
|
||||
+
|
||||
+Per the governing rule in Section 2, an exhibit's `primary` surface (Section
|
||||
+31.3) — or, when `surfaces` is absent or empty, the exhibit's ordinary entry
|
||||
+point — MUST remain fully and unconditionally usable standalone, with no
|
||||
+dependency on XZBT-NGN, on a contract session, or on any other surface.
|
||||
+
|
||||
+A non-primary surface SHOULD remain directly usable without XZBT-NGN. This
|
||||
+contract does not require every non-primary surface to be usable in complete
|
||||
+isolation from the exhibit's other documents; an exhibit MAY have a
|
||||
+non-primary surface depend on another of its own documents being present, as
|
||||
+an exhibit-internal implementation consequence of Section 31.7, provided that
|
||||
+dependency is never on XZBT-NGN itself.
|
||||
+
|
||||
+### 31.9 Non-goals
|
||||
+
|
||||
+This section defines discovery and identification of presentation surfaces
|
||||
+only. It does not define, and MUST NOT be extended by implementations to
|
||||
+imply:
|
||||
+
|
||||
+- casting or remote display protocols;
|
||||
+- network display endpoints or device classes;
|
||||
+- an assignment mechanism between a surface and a physical or logical
|
||||
+ display;
|
||||
+- any change to session, state, revision, or event semantics beyond the
|
||||
+ cross-references added in Sections 7, 9.4, 13, 14.1, 14.4, 15, 16.6, 17,
|
||||
+ 20, 23, 25, and 29 of this document.
|
||||
+
|
||||
+Those concerns are reserved for future contract or XZBT-NGN work and are
|
||||
+explicitly out of scope for Contract 5.3.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## 32. Version 5.3 Summary
|
||||
+
|
||||
+Contract 5.3 is Contract 5.2 plus one optional, additive capability:
|
||||
+presentation surfaces (Section 31). No existing normative requirement is
|
||||
+weakened, removed, or reinterpreted. An exhibit or host that implements
|
||||
+nothing in Section 31 is unaffected and remains conformant. The architectural
|
||||
+rule from Section 30 is unchanged: the exhibit exposes what it can do, the
|
||||
+contract defines how that is described and invoked, and XZBT-NGN decides how
|
||||
+to orchestrate it — now including, optionally, orchestrating which of an
|
||||
+exhibit's several presentation surfaces is currently shown.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## 39. Changelog (5.2 → 5.3)
|
||||
+
|
||||
+This section exists only in 5.3 and has no 5.2 counterpart.
|
||||
+
|
||||
+**Added:**
|
||||
+
|
||||
+- `surfaces` OPTIONAL field on `describe.result` (Section 7).
|
||||
+- Section 31, Presentation Surfaces: definition, descriptor schema, the
|
||||
+ `primary` invariant and its three conformant forms, URL resolution,
|
||||
+ validation/backward-compatibility rules, registry governance, the
|
||||
+ state/interaction guarantee, standalone-use requirements, and explicit
|
||||
+ non-goals.
|
||||
+- Section 28.8, recording the versioning rationale for this addition.
|
||||
+- Cross-reference sentences in Sections 5.2 (hello response commentary),
|
||||
+ 9.4, 13, 14.1, 14.4, 15, 16.6, 17, 20, 23, 25, 29, and 30, each noting how
|
||||
+ the existing normative rule in that section extends to, or is unaffected
|
||||
+ by, presentation surfaces. None of these cross-references change the
|
||||
+ normative requirement already stated in 5.2 for that section.
|
||||
+
|
||||
+**Changed:**
|
||||
+
|
||||
+- Header metadata (document version, compatibility statement).
|
||||
+- Illustrative `"xzbt"` and `contract.minor` values in JSON examples updated
|
||||
+ from `"5.2"` / `2` to `"5.3"` / `3` throughout, for internal consistency
|
||||
+ within this document. This is cosmetic within the example payloads and
|
||||
+ does not alter any example's normative meaning.
|
||||
+
|
||||
+**Removed:** nothing. No 5.2 requirement is weakened, deleted, or
|
||||
+reinterpreted by this document.
|
||||
+
|
||||
+**Not changed:** Sections 1–4, 6, 8.2–8.3, 10–12, 18–19, 21–22, 24, 26–27,
|
||||
+28.1–28.7 carry no 5.3-specific content and are reproduced from 5.2
|
||||
+unmodified except for the cosmetic example-version updates noted above.
|
||||
@@ -0,0 +1,650 @@
|
||||
--- /mnt/user-data/uploads/XZBT-NGN/docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md 2026-09-14 20:04:53.376627937 +0000
|
||||
+++ /home/claude/XZBT-Exhibit-Contract-Specification-v5.3.md 2026-09-14 20:34:49.665940986 +0000
|
||||
@@ -1,10 +1,14 @@
|
||||
# XZBT Exhibit Contract Specification
|
||||
-## Version 5.2
|
||||
+## Version 5.3
|
||||
|
||||
-**Status:** Proposed normative specification
|
||||
-**Document version:** 5.2
|
||||
-**Contract family:** XZBT Exhibit Contract
|
||||
-**Compatibility major:** 5
|
||||
+**Status:** Proposed normative specification
|
||||
+**Document version:** 5.3
|
||||
+**Contract family:** XZBT Exhibit Contract
|
||||
+**Compatibility major:** 5
|
||||
+**Supersedes:** Version 5.2, additively. No existing normative requirement
|
||||
+from 5.2 has been removed, weakened, or reinterpreted. Contract 5.3 adds
|
||||
+presentation-surface provisions and surface-related cross-references as
|
||||
+documented in Section 39.
|
||||
**Primary consumers:** XZBT-compatible exhibits and XZBT-NGN Exhibit Engine
|
||||
|
||||
---
|
||||
@@ -111,7 +115,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "hello",
|
||||
"requestId": "req-001",
|
||||
"host": {
|
||||
@@ -126,7 +130,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "hello.result",
|
||||
"requestId": "req-001",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -137,13 +141,15 @@
|
||||
},
|
||||
"contract": {
|
||||
"major": 5,
|
||||
- "minor": 2
|
||||
+ "minor": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no compatible major version exists, the exhibit MUST respond with an `error` carrying code `UNSUPPORTED_VERSION`, MUST echo the `requestId` when recoverable, and MUST NOT create a session.
|
||||
|
||||
+A 5.2-only exhibit reporting `{"major": 5, "minor": 2}` remains fully compatible with a 5.3-aware host under the ordinary contract-major compatibility rule (Section 28.1); nothing in this handshake requires either party to implement 5.3-specific behavior.
|
||||
+
|
||||
---
|
||||
|
||||
## 6. Message Envelope
|
||||
@@ -154,7 +160,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke",
|
||||
"requestId": "req-1042",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -169,7 +175,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke.result",
|
||||
"requestId": "req-1042",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -181,7 +187,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "error",
|
||||
"requestId": "req-1042",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -219,7 +225,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "describe",
|
||||
"requestId": "req-010",
|
||||
"sessionId": "sess-7f2a"
|
||||
@@ -230,7 +236,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "describe.result",
|
||||
"requestId": "req-010",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -241,12 +247,13 @@
|
||||
},
|
||||
"contract": {
|
||||
"major": 5,
|
||||
- "minor": 2
|
||||
+ "minor": 3
|
||||
},
|
||||
"registryRevision": 1,
|
||||
"stateRevision": 27,
|
||||
"capabilities": [],
|
||||
- "targets": []
|
||||
+ "targets": [],
|
||||
+ "surfaces": []
|
||||
}
|
||||
```
|
||||
|
||||
@@ -256,6 +263,14 @@
|
||||
|
||||
NGN MUST discover targets instead of assuming that an exhibit exposes a fixed science-fiction vocabulary.
|
||||
|
||||
+**`surfaces` (introduced in Contract 5.3).** An OPTIONAL array of presentation
|
||||
+surface descriptors, normatively defined in Section 31. Its absence, or an
|
||||
+empty array, both mean the exhibit has not adopted multi-surface presentation
|
||||
+and is functionally equivalent to a Contract 5.2 `describe.result`, which
|
||||
+never contained this field. A 5.3-aware host MUST treat a `describe.result`
|
||||
+lacking `surfaces` identically to one where `surfaces` is present and empty
|
||||
+(Section 31.5).
|
||||
+
|
||||
---
|
||||
|
||||
## 8. Canonical Target IDs
|
||||
@@ -294,6 +309,9 @@
|
||||
[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+
|
||||
```
|
||||
|
||||
+This grammar also governs presentation surface identifiers (Section 31.2);
|
||||
+no separate identifier grammar is defined for surfaces.
|
||||
+
|
||||
### 8.2 Stability
|
||||
|
||||
Once published as part of an exhibit's external contract, a canonical target ID becomes part of that exhibit's compatibility surface.
|
||||
@@ -405,6 +423,11 @@
|
||||
|
||||
`kind` is authoritative for invokability. No separate `invokable` field is defined.
|
||||
|
||||
+Presentation surfaces (Section 31) are a structurally distinct descriptor
|
||||
+family from targets and are never expressed using any target `kind`,
|
||||
+including `impulse`. A host MUST NOT infer surface existence from any target
|
||||
+descriptor.
|
||||
+
|
||||
### 9.5 Impulse arguments (authoritative Contract 5.2 clarification)
|
||||
|
||||
Impulse descriptors use `arguments`, an array of argument descriptors. This
|
||||
@@ -489,7 +512,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke",
|
||||
"requestId": "req-200",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -514,7 +537,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "set",
|
||||
"requestId": "req-201",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -537,7 +560,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "state.get",
|
||||
"requestId": "req-300",
|
||||
"sessionId": "sess-7f2a"
|
||||
@@ -548,7 +571,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "state.result",
|
||||
"requestId": "req-300",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -568,6 +591,11 @@
|
||||
|
||||
A state snapshot MUST NOT imply that every internal exhibit variable is externally exposed.
|
||||
|
||||
+State exposed through `values` is scoped to the one logical exhibit instance
|
||||
+and its one session, regardless of how many presentation surfaces (Section
|
||||
+31) are currently open. `state.get` MUST NOT be parameterized by surface, and
|
||||
+no surface-specific state view is defined by this contract.
|
||||
+
|
||||
---
|
||||
|
||||
## 14. State Revision and Mutation Transactions
|
||||
@@ -588,6 +616,10 @@
|
||||
|
||||
All contract-visible state changes committed by one transaction share one resulting `stateRevision`.
|
||||
|
||||
+A native interaction originating on any presentation surface (Section 31) is
|
||||
+one mutation transaction, subject to this same rule, regardless of which
|
||||
+surface it originated on.
|
||||
+
|
||||
### 14.2 Ordering
|
||||
|
||||
The exhibit MUST:
|
||||
@@ -608,6 +640,11 @@
|
||||
|
||||
The counters serve different purposes and MUST NOT be treated as interchangeable.
|
||||
|
||||
+Multi-surface presentation does not introduce a second revision or sequence
|
||||
+counter of any kind. Every presentation surface of one exhibit instance
|
||||
+observes the same `stateRevision` history and the same event `sequence`
|
||||
+stream defined here.
|
||||
+
|
||||
---
|
||||
|
||||
## 15. Source and Origin
|
||||
@@ -632,6 +669,12 @@
|
||||
|
||||
This distinction is required for recording and feedback-loop prevention.
|
||||
|
||||
+An interaction originating on a presentation surface other than the primary
|
||||
+surface (Section 31.3) uses the same `source` vocabulary as an interaction on
|
||||
+the primary surface — typically `ui`. This contract does not define a
|
||||
+per-surface source value; which surface an interaction originated on is not
|
||||
+contract-visible.
|
||||
+
|
||||
---
|
||||
|
||||
## 16. Events
|
||||
@@ -653,7 +696,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "state.changed",
|
||||
"sessionId": "sess-7f2a",
|
||||
"sequence": 144,
|
||||
@@ -669,7 +712,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "action.executed",
|
||||
"sessionId": "sess-7f2a",
|
||||
"sequence": 145,
|
||||
@@ -710,6 +753,13 @@
|
||||
|
||||
It SHOULD NOT automatically record its own `scenario` or host playback events back into the scenario being recorded unless explicitly configured.
|
||||
|
||||
+### 16.6 `registry.changed` and surfaces (Contract 5.3)
|
||||
+
|
||||
+`registry.changed` (Section 23) covers changes to `surfaces` in addition to
|
||||
+`targets` and capability metadata. A single `registryRevision` governs both;
|
||||
+this contract does not define a separate surface-registry revision. See
|
||||
+Section 31.6.
|
||||
+
|
||||
---
|
||||
|
||||
## 17. Capability Model
|
||||
@@ -751,6 +801,10 @@
|
||||
|
||||
A host MUST treat the current discovered state as authoritative.
|
||||
|
||||
+A presentation surface (Section 31) MAY declare `requires` against
|
||||
+capability IDs defined here, using identical semantics to target `requires`
|
||||
+(Section 9).
|
||||
+
|
||||
---
|
||||
|
||||
## 18. Speech
|
||||
@@ -765,7 +819,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "invoke",
|
||||
"requestId": "req-410",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -843,6 +897,9 @@
|
||||
|
||||
Each text target SHOULD declare a maximum accepted length.
|
||||
|
||||
+This restriction applies equally to any text rendered by a presentation
|
||||
+surface (Section 31); surfaces introduce no new text-injection surface area.
|
||||
+
|
||||
---
|
||||
|
||||
## 21. Telemetry
|
||||
@@ -899,7 +956,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
- "xzbt": "5.2",
|
||||
+ "xzbt": "5.3",
|
||||
"type": "set",
|
||||
"requestId": "req-520",
|
||||
"sessionId": "sess-7f2a",
|
||||
@@ -914,7 +971,7 @@
|
||||
|
||||
After lease expiration, the exhibit MAY return to simulated ownership.
|
||||
|
||||
-The exact telemetry lease mechanism remains optional in 5.2.
|
||||
+The exact telemetry lease mechanism remains optional in 5.2, unchanged in 5.3.
|
||||
|
||||
A simple explicit release mechanism is also acceptable.
|
||||
|
||||
@@ -932,6 +989,10 @@
|
||||
|
||||
Registry revision SHOULD NOT change merely because a fixed target becomes contextually unavailable.
|
||||
|
||||
+As of Contract 5.3, `registryRevision` also governs the `surfaces` array
|
||||
+(Section 31.6). One counter covers both; this contract does not define an
|
||||
+independent surface-registry revision.
|
||||
+
|
||||
---
|
||||
|
||||
## 24. Error Codes
|
||||
@@ -978,6 +1039,11 @@
|
||||
|
||||
Same-origin `postMessage` transports MUST validate both `event.origin` and `event.source` against the expected host relationship.
|
||||
|
||||
+The same-origin requirement in this section extends to presentation surface
|
||||
+resolution and to any exhibit-internal attachment channel used between an
|
||||
+exhibit's own documents (Section 31.7). Neither introduces a new trust
|
||||
+boundary beyond the one already defined here.
|
||||
+
|
||||
---
|
||||
|
||||
## 26. Scenario Independence
|
||||
@@ -1056,6 +1122,16 @@
|
||||
|
||||
An exhibit may add targets without changing the contract version, provided existing target semantics remain compatible.
|
||||
|
||||
+### 28.8 Contract 5.3 (presentation surfaces)
|
||||
+
|
||||
+Contract 5.3 adds the optional `surfaces` field to `describe.result`
|
||||
+(Section 7) and the normative Presentation Surfaces model (Section 31), per
|
||||
+the rule in 28.2: this is a backward-compatible addition, not a breaking
|
||||
+change. Contract major remains 5. An exhibit reporting `{major: 5, minor: 2}`
|
||||
+is unaffected by this addition and remains fully conformant; a 5.3-aware host
|
||||
+MUST continue to interoperate with it exactly as under Contract 5.2 (Section
|
||||
+31.5).
|
||||
+
|
||||
---
|
||||
|
||||
## 29. Conformance Minimum
|
||||
@@ -1076,6 +1152,11 @@
|
||||
|
||||
Speech, telemetry, MIDI, scenarios, Web3D, and any particular exhibit namespace are optional capabilities.
|
||||
|
||||
+This conformance minimum is unchanged by Contract 5.3. Presentation surfaces
|
||||
+(Section 31) are an optional capability of the same kind: an exhibit that
|
||||
+implements none of Section 31 and never emits `surfaces` remains fully
|
||||
+conformant, at either contract minor.
|
||||
+
|
||||
---
|
||||
|
||||
## 30. Architectural Summary
|
||||
@@ -1089,3 +1170,278 @@
|
||||
**The contract defines how that capability is described, observed, and invoked.**
|
||||
|
||||
**XZBT-NGN decides how to orchestrate and integrate it.**
|
||||
+
|
||||
+Version 5.3 preserves this architecture without modification and adds exactly
|
||||
+one optional capability class — presentation surfaces (Section 31) — built
|
||||
+entirely on the existing session, state-revision, event, and registry-revision
|
||||
+mechanisms defined above.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## 31. Presentation Surfaces (introduced in Contract 5.3)
|
||||
+
|
||||
+This section is new in Contract 5.3. It formalizes the multi-surface
|
||||
+presentation model approved in XZBT-NGN's Step 6.1 architecture document
|
||||
+(`docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md`, Revision 2). Its
|
||||
+scope is narrowly the discovery and identification of presentation surfaces
|
||||
+and the contract-visible guarantees around them; it does not define display
|
||||
+transport, casting, or remote endpoints (explicitly out of scope — see
|
||||
+Section 31.9).
|
||||
+
|
||||
+### 31.1 Definition
|
||||
+
|
||||
+A **presentation surface** is a renderable, full-screen-capable visual view
|
||||
+of one logical exhibit instance, addressable by a stable identifier, that an
|
||||
+exhibit advertises as independently viewable. Every presentation surface of
|
||||
+one exhibit instance is driven by that exhibit's one authoritative
|
||||
+`stateRevision` history and one event `sequence` stream (Sections 13, 14,
|
||||
+16); this contract defines no mechanism by which two surfaces of the same
|
||||
+exhibit instance could observe divergent state.
|
||||
+
|
||||
+A presentation surface is not a target (Section 9), not a capability
|
||||
+(Section 17), and not the host's own control/administration interface. It
|
||||
+MUST NOT be represented using any target `kind`.
|
||||
+
|
||||
+### 31.2 Surface descriptor
|
||||
+
|
||||
+When an exhibit advertises presentation surfaces, each entry in the
|
||||
+`surfaces` array (Section 7) MUST be an object with the following fields:
|
||||
+
|
||||
+| Field | Requirement | Notes |
|
||||
+| --- | --- | --- |
|
||||
+| `id` | REQUIRED string | MUST conform to the canonical target-ID grammar (Section 8.1). No separate identifier grammar is defined for surfaces. |
|
||||
+| `label` | REQUIRED string | Human-readable. |
|
||||
+| `kind` | REQUIRED, constant `"surface"` | Identifies the descriptor type. |
|
||||
+| `primary` | REQUIRED boolean | Governed by Section 31.3. |
|
||||
+| `url` | REQUIRED string | Governed by Section 31.4. |
|
||||
+
|
||||
+The following fields are OPTIONAL:
|
||||
+
|
||||
+| Field | Purpose |
|
||||
+| --- | --- |
|
||||
+| `description` | Longer human-readable text. |
|
||||
+| `role` | Advisory grouping hint (e.g. `"control"`, `"ambient"`, `"information"`). No contract behavior depends on this field's value in Contract 5.3. |
|
||||
+| `aspectRatio` | Advisory sizing hint (e.g. `"16:9"`). |
|
||||
+| `category` | Mirrors target `category` (Section 9) for consistent grouping. |
|
||||
+| `requires` | Array of capability IDs (Section 17), with identical semantics to target `requires`: the surface remains discoverable but SHOULD be presented as degraded or unavailable when a required capability is not `ready`. |
|
||||
+
|
||||
+No field of a surface descriptor MAY reference a display transport, casting
|
||||
+protocol, network endpoint, or device class (Section 31.9).
|
||||
+
|
||||
+### 31.3 The `primary` invariant and validation order
|
||||
+
|
||||
+`surfaces` (Section 7) has exactly three conformant top-level forms:
|
||||
+
|
||||
+1. **Absent.** The exhibit does not advertise multi-surface presentation.
|
||||
+ Functionally equivalent to a Contract 5.2 `describe.result`.
|
||||
+2. **Present and empty (`[]`).** MUST be treated identically to form 1 by a
|
||||
+ conformant host.
|
||||
+3. **Present and non-empty.** Evaluated using the deterministic validation
|
||||
+ order below.
|
||||
+
|
||||
+A host MUST evaluate a non-empty `surfaces` array in the following order:
|
||||
+
|
||||
+1. Validate each individual surface descriptor against Section 31.2 and
|
||||
+ Section 31.4.
|
||||
+2. Discard individually invalid entries (Section 31.5); retain only the
|
||||
+ valid entries as the working set.
|
||||
+3. Evaluate the `primary` invariant against the working set, not against the
|
||||
+ original array:
|
||||
+ - If the working set is empty (every entry was individually invalid),
|
||||
+ treat `surfaces` as if it were absent (form 1).
|
||||
+ - If the working set contains exactly one entry with `primary: true`,
|
||||
+ that entry is the primary surface and the working set is the
|
||||
+ conformant surface catalog for this `describe.result`.
|
||||
+ - If the working set contains zero entries with `primary: true`, or more
|
||||
+ than one, the surface catalog as a whole is malformed: the host MUST
|
||||
+ reject the entire working set (not just the offending entries), fall
|
||||
+ back to treating `surfaces` as absent (form 1), and SHOULD report a
|
||||
+ diagnostic.
|
||||
+
|
||||
+This order is intentional and normative: the `primary` invariant is always
|
||||
+evaluated after individually-invalid entries have already been discarded, so
|
||||
+that one malformed entry cannot be mistaken for a missing or duplicate
|
||||
+`primary` among otherwise-valid entries, and so that discarding a malformed
|
||||
+entry can deterministically change whether the invariant holds.
|
||||
+
|
||||
+The primary surface, once determined by this process, is the surface a
|
||||
+Section-31-aware host that provides no operator surface selection opens by
|
||||
+default, and is the surface whose standalone-use guarantee is unconditional
|
||||
+(Section 31.8).
|
||||
+
|
||||
+### 31.4 URL resolution
|
||||
+
|
||||
+`url` MUST be one of:
|
||||
+
|
||||
+- a path relative to the exhibit's own base document location — the same
|
||||
+ base URL already used to establish the exhibit's session;
|
||||
+- such a relative path with an appended query string and/or fragment;
|
||||
+- 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 host resolving `url` MUST:
|
||||
+
|
||||
+1. resolve it against the exhibit's already-established base URL, not
|
||||
+ against the host's own administrative interface location;
|
||||
+2. reject the entry (Section 31.5) if the resolved URL is not same-origin
|
||||
+ with that base, per the same-origin requirement in Section 25;
|
||||
+3. apply the same path-containment validation the host already applies to
|
||||
+ the exhibit's primary document, if any such validation exists in that
|
||||
+ host implementation.
|
||||
+
|
||||
+Absolute, protocol-relative, or cross-origin `url` values MUST be rejected as
|
||||
+malformed individual entries; they do not invalidate the rest of the array.
|
||||
+
|
||||
+### 31.5 Individual entry validation
|
||||
+
|
||||
+A host MUST validate each surface entry independently, as step 1 of the
|
||||
+order defined in Section 31.3. An entry missing a required field, using an
|
||||
+invalid `id` (Section 8.1), or specifying a `url` that fails Section 31.4 is
|
||||
+individually invalid; the host SHOULD skip only that entry, log a
|
||||
+diagnostic, and continue evaluating the remainder of `surfaces`. Individual
|
||||
+entry validation never by itself invalidates the whole array — only the
|
||||
+structural `primary` evaluation performed afterward, against the surviving
|
||||
+valid entries (Section 31.3, steps 2–3), can do that.
|
||||
+
|
||||
+A host that does not implement any Section 31 behavior MAY safely ignore the
|
||||
+`surfaces` field entirely; doing so is fully conformant with the
|
||||
+`describe.result` schema, since the field is OPTIONAL (Section 7).
|
||||
+
|
||||
+An exhibit implementing only Contract 5.2 behavior is unaffected: it never
|
||||
+emits `surfaces`, and no 5.3-only requirement applies to it.
|
||||
+
|
||||
+### 31.6 Registry governance
|
||||
+
|
||||
+`surfaces` is governed by `registryRevision` (Section 23) exactly as
|
||||
+`targets` is. There is no independent surface-registry counter. A
|
||||
+`registry.changed` event (Section 16.6) requires the host to re-run
|
||||
+`describe` and re-read both `targets` and `surfaces`.
|
||||
+
|
||||
+### 31.7 State and interaction
|
||||
+
|
||||
+All presentation surfaces of one exhibit instance share that instance's one
|
||||
+session, one `stateRevision` history, and one event `sequence` stream
|
||||
+(Sections 13, 14, 16); this contract defines no per-surface state channel
|
||||
+and no per-surface session. How an exhibit internally propagates state to
|
||||
+each surface's rendering code is an exhibit implementation detail outside
|
||||
+this contract's normative scope.
|
||||
+
|
||||
+The normative requirement on interaction is narrower than "every native
|
||||
+interaction becomes a public target": any surface-originated interaction
|
||||
+that changes contract-visible persistent state or executes a contract-visible
|
||||
+action MUST obey the same mutation, revision, source, and event semantics
|
||||
+(Sections 14, 15, 16) as the equivalent operation originating from the
|
||||
+exhibit's primary UI or through a contract `set`/`invoke` (Section 14.1). A
|
||||
+surface-local interaction that does not change contract-visible state and
|
||||
+does not execute a contract-visible action — purely presentational
|
||||
+interaction confined to that surface — remains exhibit-internal and is
|
||||
+outside this contract's scope; this contract does not require it to have a
|
||||
+corresponding target.
|
||||
+
|
||||
+An exhibit's internal mechanism for connecting a surface's rendering code to
|
||||
+its own state, including any same-origin channel used between the exhibit's
|
||||
+own documents, is not part of the message envelope defined in Section 6 and
|
||||
+is never observed by the host.
|
||||
+
|
||||
+### 31.8 Standalone behavior
|
||||
+
|
||||
+Per the governing rule in Section 2, an exhibit's `primary` surface (Section
|
||||
+31.3) — or, when `surfaces` is absent or empty, the exhibit's ordinary entry
|
||||
+point — MUST remain fully and unconditionally usable standalone, with no
|
||||
+dependency on XZBT-NGN, on a contract session, or on any other surface.
|
||||
+
|
||||
+A non-primary surface SHOULD remain directly usable without XZBT-NGN. This
|
||||
+contract does not require every non-primary surface to be usable in complete
|
||||
+isolation from the exhibit's other documents; an exhibit MAY have a
|
||||
+non-primary surface depend on another of its own documents being present, as
|
||||
+an exhibit-internal implementation consequence of Section 31.7, provided that
|
||||
+dependency is never on XZBT-NGN itself.
|
||||
+
|
||||
+### 31.9 Non-goals
|
||||
+
|
||||
+This section defines discovery and identification of presentation surfaces
|
||||
+only. It does not define, and MUST NOT be extended by implementations to
|
||||
+imply:
|
||||
+
|
||||
+- casting or remote display protocols;
|
||||
+- network display endpoints or device classes;
|
||||
+- an assignment mechanism between a surface and a physical or logical
|
||||
+ display;
|
||||
+- any change to session, state, revision, or event semantics beyond the
|
||||
+ cross-references added in Sections 7, 9.4, 13, 14.1, 14.4, 15, 16.6, 17,
|
||||
+ 20, 23, 25, and 29 of this document.
|
||||
+
|
||||
+Those concerns are reserved for future contract or XZBT-NGN work and are
|
||||
+explicitly out of scope for Contract 5.3.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## 32. Version 5.3 Summary
|
||||
+
|
||||
+Contract 5.3 is Contract 5.2 plus one optional, additive capability:
|
||||
+presentation surfaces (Section 31). No existing normative requirement is
|
||||
+weakened, removed, or reinterpreted. An exhibit or host that implements
|
||||
+nothing in Section 31 is unaffected and remains conformant. The architectural
|
||||
+rule from Section 30 is unchanged: the exhibit exposes what it can do, the
|
||||
+contract defines how that is described and invoked, and XZBT-NGN decides how
|
||||
+to orchestrate it — now including, optionally, orchestrating which of an
|
||||
+exhibit's several presentation surfaces is currently shown.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## 39. Changelog (5.2 → 5.3)
|
||||
+
|
||||
+This section exists only in 5.3 and has no 5.2 counterpart.
|
||||
+
|
||||
+**Added:**
|
||||
+
|
||||
+- `surfaces` OPTIONAL field on `describe.result` (Section 7).
|
||||
+- Section 31, Presentation Surfaces: definition, descriptor schema, the
|
||||
+ `primary` invariant and its three conformant forms, URL resolution,
|
||||
+ validation/backward-compatibility rules, registry governance, the
|
||||
+ state/interaction guarantee, standalone-use requirements, and explicit
|
||||
+ non-goals.
|
||||
+- Section 28.8, recording the versioning rationale for this addition.
|
||||
+- Cross-reference sentences in Sections 5.2 (hello response commentary),
|
||||
+ 9.4, 13, 14.1, 14.4, 15, 16.6, 17, 20, 23, 25, 29, and 30, each noting how
|
||||
+ the existing normative rule in that section extends to, or is unaffected
|
||||
+ by, presentation surfaces. None of these cross-references change the
|
||||
+ normative requirement already stated in 5.2 for that section.
|
||||
+
|
||||
+**Changed:**
|
||||
+
|
||||
+- Header metadata (document version, compatibility statement) — the
|
||||
+ supersedes wording was tightened during the pre-6.2 cleanup pass to state
|
||||
+ plainly that no existing 5.2 requirement was removed, weakened, or
|
||||
+ reinterpreted, rather than characterizing which sections happened to
|
||||
+ receive cross-references.
|
||||
+- Illustrative `"xzbt"` and `contract.minor` values in JSON examples updated
|
||||
+ from `"5.2"` / `2` to `"5.3"` / `3` throughout, for internal consistency
|
||||
+ within this document. This is cosmetic within the example payloads and
|
||||
+ does not alter any example's normative meaning.
|
||||
+- Section 31.3 was restructured, during the same cleanup pass, into an
|
||||
+ explicit, deterministic validation order (validate individual entries →
|
||||
+ discard invalid ones → evaluate the `primary` invariant against the
|
||||
+ surviving set) so that a discarded individually-invalid entry cannot leave
|
||||
+ the `primary` determination ambiguous. Section 31.5 was narrowed to cover
|
||||
+ only individual-entry validation and now cross-references 31.3 for the
|
||||
+ structural `primary` evaluation, rather than restating it. This is a
|
||||
+ clarification of Revision 2's intent, not a new rule.
|
||||
+- Section 31.7's interaction requirement was narrowed from "every successful
|
||||
+ surface interaction behaves like `set`/`invoke`" to the intended scope:
|
||||
+ this requirement applies only to interactions that change contract-visible
|
||||
+ state or execute a contract-visible action. Purely surface-local,
|
||||
+ non-contract-visible interaction is explicitly out of scope and is not
|
||||
+ required to have a corresponding target.
|
||||
+- Section 31.3's reference to "a host with no surface-specific UI" was
|
||||
+ reworded to "a Section-31-aware host that provides no operator surface
|
||||
+ selection," to avoid it being read as covering a host that ignores Section
|
||||
+ 31 entirely.
|
||||
+
|
||||
+**Removed:** nothing. No 5.2 requirement is weakened, deleted, or
|
||||
+reinterpreted by this document.
|
||||
+
|
||||
+**Not changed:** Sections 1–4, 6, 8.2–8.3, 10–12, 18–19, 21–22, 24, 26–27,
|
||||
+28.1–28.7 carry no 5.3-specific content and are reproduced from 5.2
|
||||
+unmodified except for the cosmetic example-version updates noted above.
|
||||
@@ -0,0 +1,755 @@
|
||||
# XZBT Multi-Surface Presentation Model — Step 6.1
|
||||
|
||||
## Status
|
||||
|
||||
**Revision 2 — for final approval.** Contract 5.3 has been approved by the
|
||||
owner as the intended version for multi-surface presentation (Section 14).
|
||||
The versioned `v5.3.md` contract file has **not** been minted and Phase 6.2
|
||||
has **not** begun; both remain gated on approval of this revised document.
|
||||
|
||||
This revision addresses five items raised against Revision 1:
|
||||
|
||||
1. defines a generic surface-attachment mechanism (new Section 7.2);
|
||||
2. restores the canonical target-ID grammar for surface IDs, with no carve-out
|
||||
(Section 5.1);
|
||||
3. tightens `primary` and backward-compatibility semantics, resolving the
|
||||
ambiguity previously left as Open Question 1 (Section 6);
|
||||
4. relaxes the direct-open standalone requirement so it no longer conflicts
|
||||
with the new attachment mechanism (Section 10);
|
||||
5. adds an explicit URL-resolution algorithm for the surface `url` field
|
||||
(new Section 6.3).
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Step 5 closed the XZBT-NGN MVP around a single implicit assumption: one
|
||||
exhibit has one renderable visual surface, shown in the one iframe the host
|
||||
loads.
|
||||
|
||||
Step 6 removes that assumption. An XZBT-compatible exhibit may have more than
|
||||
one full-screen visual presentation — a main interface, an observation
|
||||
display, an information wall, a second dome view — that are all views onto
|
||||
**one logical exhibit instance and one authoritative state**, not independent
|
||||
exhibits.
|
||||
|
||||
This document defines:
|
||||
|
||||
- what a presentation surface is and is not;
|
||||
- how an exhibit advertises its surfaces;
|
||||
- the generic mechanism by which a surface document attaches to the one
|
||||
authoritative exhibit state, regardless of exhibit implementation;
|
||||
- how surfaces stay synchronized to that one state;
|
||||
- the lifecycle a surface goes through;
|
||||
- who owns what (exhibit / NGN / transport / display endpoint);
|
||||
- whether this requires a new contract version;
|
||||
- what remains explicitly out of scope (Step 7).
|
||||
|
||||
It does not define casting, remote displays, or any display-transport
|
||||
mechanism. Those are Step 7.
|
||||
|
||||
---
|
||||
|
||||
## 2. Definitions
|
||||
|
||||
**Logical exhibit instance.** One running exhibit session, with one
|
||||
`sessionId`, one `stateRevision` history, and one authoritative set of
|
||||
contract-visible persistent state values, as already defined by Contract 5.2
|
||||
§13–§14. Multi-surface support does not change this definition; it depends on
|
||||
it remaining singular.
|
||||
|
||||
**Presentation surface.** A renderable, full-screen(-capable) visual view of
|
||||
the logical exhibit instance, addressable by a stable identifier, that an
|
||||
exhibit advertises as independently viewable. A surface displays a coherent
|
||||
subset (or the whole) of the exhibit's presentation, driven by the same
|
||||
underlying state and the same contract session as every other surface of the
|
||||
same exhibit.
|
||||
|
||||
**Surface descriptor.** The machine-readable metadata an exhibit publishes
|
||||
describing one presentation surface: identifier, label, lifecycle-relevant
|
||||
metadata, and enough information for a generic host to open it without any
|
||||
exhibit-specific knowledge.
|
||||
|
||||
**Exhibit State Core.** The single in-exhibit authority for contract-visible
|
||||
state, mutation, and event fan-out. Every exhibit already has exactly one of
|
||||
these today, whether or not it is named as such — it is whatever internal
|
||||
object/module the existing contract adapter (e.g.
|
||||
`test-fixtures/reference-exhibits/shared/contract-core.js`) reads and writes.
|
||||
Multi-surface support does not introduce a new core; it requires that every
|
||||
surface, however many documents or windows are involved, attach to that one
|
||||
existing core rather than instantiate a second one (Section 7.2).
|
||||
|
||||
**Control UI.** The operator-facing interface NGN renders from `describe`
|
||||
(targets, state, events). It is not a presentation surface. It may run
|
||||
alongside any number of open presentation surfaces.
|
||||
|
||||
**Display endpoint (Step 7, referenced only for contrast).** Where a surface
|
||||
is physically or logically shown — a local browser window, a monitor, a
|
||||
casting receiver. Step 6 defines *what* can be shown; Step 7 defines *where*.
|
||||
|
||||
---
|
||||
|
||||
## 3. Surface vs. Target Distinction
|
||||
|
||||
Contract 5.2 already has a generic extensibility mechanism: targets
|
||||
(state/range/selection/impulse) discovered through `describe`. It is tempting
|
||||
to model a surface as a `state`-kind target (e.g. `surface.observation.open:
|
||||
boolean`). This proposal rejects that approach for surfaces themselves, for
|
||||
four reasons:
|
||||
|
||||
1. **A target represents a piece of exhibit state or an action on it.** A
|
||||
surface is neither — it is a *view*. Modeling "is this surface open" as
|
||||
exhibit state conflates NGN-side presentation bookkeeping with exhibit
|
||||
domain state, which Contract §2 explicitly prohibits ("The contract MUST
|
||||
NOT be expanded merely to move Exhibit Engine responsibilities into the
|
||||
exhibit").
|
||||
2. **Targets have no place to carry surface-specific metadata** a generic
|
||||
host needs to *render a window* rather than *render a control* — no field
|
||||
in the base or kind-specific descriptors (§9) accommodates a document URL,
|
||||
an aspect ratio hint, or a role.
|
||||
3. **A target-based encoding would force every exhibit author to hand-roll an
|
||||
ad hoc convention** (e.g. a `surface.*` naming prefix) for something that
|
||||
should be a first-class, uniformly validated concept — exactly the kind of
|
||||
silent protocol behavior the Step 6 brief prohibits inventing.
|
||||
4. **Surfaces are discovered once per registry revision, not read/written
|
||||
like state.** Their lifecycle (open/visible/hidden/closed) is host-side
|
||||
presentation bookkeeping, not a `set`/`invoke` operation on exhibit state.
|
||||
|
||||
Surfaces are therefore modeled as a **new, parallel discovery construct**:
|
||||
a `surfaces` array returned by `describe`, structurally independent of
|
||||
`targets` and `capabilities`, but discovered through the same message and
|
||||
governed by the same `registryRevision`.
|
||||
|
||||
**What is NOT a presentation surface:**
|
||||
|
||||
- the control/admin UI NGN renders from targets — that is an NGN-owned
|
||||
concept and never appears in `surfaces`;
|
||||
- a capability (§17) — capabilities describe whether a subsystem is usable,
|
||||
not whether a view exists;
|
||||
- a target of any kind — including a hypothetical `kind: "surface"` target,
|
||||
which this proposal does not introduce;
|
||||
- a display endpoint — monitors, browser windows, casting receivers are
|
||||
Step 7 and are never named inside a surface descriptor;
|
||||
- a scenario, recording, or packaging artifact.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ownership Model
|
||||
|
||||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| What surfaces exist, their identifiers, labels, and descriptors | **Exhibit** |
|
||||
| The actual rendering of each surface (HTML/CSS/JS/canvas/audio) | **Exhibit** |
|
||||
| One authoritative state, `stateRevision`, and event stream (the Exhibit State Core) | **Exhibit**, shared by all surfaces of that exhibit |
|
||||
| How surface documents attach to the Exhibit State Core internally | **Exhibit** (Section 7.2 defines the generic pattern; the specific transport choice is an exhibit implementation decision) |
|
||||
| Discovering surfaces from `describe` and reacting to `registry.changed` | **NGN** |
|
||||
| Deciding which surfaces are currently open and where | **NGN** (Step 6: local rendering only) |
|
||||
| Routing a native in-surface interaction back into one canonical mutation path | **Exhibit internally**, using the same contract-visible state mechanism regardless of which surface originated the interaction |
|
||||
| Transport connecting NGN's contract session to the exhibit | **Exhibit + transport binding**, unchanged from Contract §4 |
|
||||
| Where a surface is physically displayed (monitor, window, cast target) | **Step 7 — out of scope here** |
|
||||
|
||||
The exhibit remains the single owner of truth. NGN never maintains a second
|
||||
copy of exhibit state per surface; it discovers surfaces, opens/closes their
|
||||
presentation, and continues to observe the one session's state and events
|
||||
exactly as it does today. This is the direct application of Contract §2: the
|
||||
exhibit exposes capabilities and state, NGN decides how to coordinate
|
||||
presentation of them.
|
||||
|
||||
---
|
||||
|
||||
## 5. Surface Descriptor Proposal
|
||||
|
||||
### 5.1 Minimum required fields
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "surface.control",
|
||||
"label": "Control Room",
|
||||
"kind": "surface",
|
||||
"primary": true,
|
||||
"url": "control.html"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Requirement | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | REQUIRED string | **MUST conform exactly to the canonical target-ID grammar in Contract §8.1** — lowercase ASCII letters, digits, hyphens, and dots; begins with a lowercase letter; one or more dot-separated segments; no whitespace; no empty segments; recommended grammar `[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+`. No surface-specific carve-out exists: a bare single-segment ID (e.g. `"observation"`) is **not** valid, exactly as it would not be valid for a target ID. Authors are expected to namespace surface IDs meaningfully (e.g. `surface.control`, `surface.observation`, or an exhibit-specific namespace such as `gallery.artifact-wall`), the same discretion Contract §8 already gives target authors. Stability rule of §8.2 applies identically: once published, a surface ID is part of the exhibit's compatibility surface and SHOULD NOT be renamed casually. |
|
||||
| `label` | REQUIRED string | Human-readable, for generic host display. |
|
||||
| `kind` | REQUIRED, constant `"surface"` | Distinguishes this array's entries from targets/capabilities defensively, even though they already live in a separate array; makes the descriptor self-describing if ever handled outside its parent array. |
|
||||
| `primary` | REQUIRED boolean | See Section 6.2 for the exact structural invariant and its enforcement. |
|
||||
| `url` | REQUIRED string | See Section 6.3 for the full resolution algorithm. Summary: a same-origin, relative reference (path, and/or query/hash) resolved against the exhibit's own base URL — never an absolute or cross-origin URL. |
|
||||
|
||||
### 5.2 Optional fields (useful, not yet mandatory)
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `description` | Longer human-readable text for admin display. |
|
||||
| `role` | Free-text or small controlled vocabulary hint (e.g. `"control"`, `"ambient"`, `"information"`) for host UI grouping. Advisory only; no contract behavior depends on it in 5.3. |
|
||||
| `aspectRatio` | e.g. `"16:9"`. Advisory hint for window sizing. |
|
||||
| `category` | Mirrors target `category` (§9) for consistent grouping conventions across the contract. |
|
||||
| `requires` | Array of capability IDs (§17), same semantics as target `requires`: the surface remains discoverable but SHOULD be presented as degraded/unavailable if a required capability is not `ready`. |
|
||||
|
||||
### 5.3 Explicitly excluded fields
|
||||
|
||||
No field in the surface descriptor may name a display transport, a casting
|
||||
protocol, a network endpoint, a device class, or a rendering target other than
|
||||
"a document this host can load." This is the Step 6/Step 7 boundary enforced
|
||||
at the schema level, not just by convention.
|
||||
|
||||
---
|
||||
|
||||
## 6. Discovery Proposal
|
||||
|
||||
Surfaces are discovered exactly where targets and capabilities are discovered:
|
||||
in the `describe.result` response, as a new top-level array:
|
||||
|
||||
```json
|
||||
{
|
||||
"xzbt": "5.3",
|
||||
"type": "describe.result",
|
||||
"requestId": "req-010",
|
||||
"sessionId": "sess-7f2a",
|
||||
"exhibit": { "product": "museum-gallery", "version": "0.1.0", "build": "dev" },
|
||||
"contract": { "major": 5, "minor": 3 },
|
||||
"registryRevision": 1,
|
||||
"stateRevision": 4,
|
||||
"capabilities": [],
|
||||
"targets": [],
|
||||
"surfaces": [
|
||||
{ "id": "surface.control", "label": "Control Room", "kind": "surface", "primary": true, "url": "control.html" },
|
||||
{ "id": "surface.artifact", "label": "Artifact Display", "kind": "surface", "primary": false, "url": "artifact.html" },
|
||||
{ "id": "surface.info-wall", "label": "Information Wall", "kind": "surface", "primary": false, "url": "info-wall.html" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `surfaces` is governed by `registryRevision`, identically to `targets`. A
|
||||
`registry.changed` event MUST cause NGN to re-run `describe`, which
|
||||
re-reads the current surface set along with the current target set. There
|
||||
is no separate "surface revision" counter — one registry, one revision,
|
||||
covering both targets and surfaces (Section 9).
|
||||
|
||||
### 6.1 Malformed individual entries
|
||||
|
||||
A malformed *individual* surface entry (missing `id`/`label`/`url`, invalid
|
||||
`id` grammar per §8.1, non-relative or unresolvable `url` per Section 6.3) is
|
||||
handled the same way a malformed target entry is already handled: the host
|
||||
SHOULD skip only that entry and log a diagnostic, rather than discarding the
|
||||
entire `surfaces` array. This mirrors the existing pattern in
|
||||
`src/validation.js` and must be proven, not merely asserted, in Phase 6.3.
|
||||
|
||||
### 6.2 `primary` — structural invariant and backward compatibility
|
||||
|
||||
This is tightened relative to the prior revision to remove ambiguity.
|
||||
|
||||
**Three, and only three, recognized forms of `surfaces`:**
|
||||
|
||||
1. **Field absent.** The exhibit does not advertise multi-surface support at
|
||||
all — an ordinary Contract 5.2 exhibit, or a 5.3 exhibit that has not
|
||||
adopted the feature. A 5.3-aware host MUST behave exactly as NGN does
|
||||
today: treat the exhibit's currently-loaded document/frame as one
|
||||
implicit primary surface, with no surface picker UI, no `surfaces`
|
||||
validation, and no behavior change from Step 5. This is the sole
|
||||
legacy/compatibility path and needs no special-casing beyond "the field
|
||||
isn't there."
|
||||
2. **Field present and empty (`[]`).** Treated **identically** to the field
|
||||
being absent (form 1). This removes the ambiguity left open in the prior
|
||||
revision: an exhibit MAY emit `surfaces: []` (e.g. because it computes the
|
||||
field programmatically) without that being treated as an error or as
|
||||
"zero surfaces available." A host MUST NOT distinguish `[]` from absence.
|
||||
3. **Field present and non-empty.** The array MUST contain **exactly one**
|
||||
entry with `primary: true`. This is a *structural* invariant over the
|
||||
whole array, not a per-entry validation concern (unlike the field-level
|
||||
checks in Section 6.1). If the array contains zero `primary: true`
|
||||
entries, or more than one, the **entire `surfaces` array is rejected as
|
||||
malformed** — not just the offending entries — and the host falls back to
|
||||
form 1 behavior (implicit single primary surface = current frame), logging
|
||||
one clear diagnostic identifying the exhibit and the violation. This
|
||||
fallback exists specifically so a broken multi-surface descriptor
|
||||
degrades to "acts like Step 5" rather than to an undefined or partially
|
||||
rendered surface list.
|
||||
|
||||
**Why this matters:** `primary` is what lets a 5.3-aware host that has *not*
|
||||
been updated with any surface-picker UI still do the right thing by default
|
||||
(open the primary surface, ignore the rest) — and it is what tells a fully
|
||||
updated host which surface to treat as "the" exhibit view when no operator
|
||||
choice has been made yet. Exactly one candidate for that role must exist
|
||||
whenever `surfaces` claims to describe more than nothing.
|
||||
|
||||
### 6.3 URL resolution
|
||||
|
||||
`url` MUST be one of:
|
||||
|
||||
- a path relative to the exhibit's own base document location — the same
|
||||
base URL NGN already resolved to load the exhibit's primary document in
|
||||
the first place (i.e., the same resolution basis `src/connection.js` uses
|
||||
today for the initial exhibit URL, not NGN's own admin-page location, and
|
||||
not the requesting surface's future location);
|
||||
- such a relative path with an appended query string and/or fragment
|
||||
(`index.html?view=observation`, `control.html#panel-2`);
|
||||
- a bare query string and/or fragment with no path segment
|
||||
(`#observation`, `?surface=observation`), for a single-page exhibit where
|
||||
surfaces are views within one already-served document rather than separate
|
||||
files. In this form, resolution yields the exhibit's own base document URL
|
||||
with that query/fragment applied.
|
||||
|
||||
**Resolution algorithm:**
|
||||
|
||||
1. Start from the exhibit's currently-established base URL — the same
|
||||
absolute URL already used to load the exhibit and already validated by
|
||||
`src/connection.js`'s same-origin/path-containment checks.
|
||||
2. Resolve `url` against that base using standard relative-URL resolution.
|
||||
3. The resolved absolute URL MUST remain same-origin with the base (Contract
|
||||
§25's same-origin requirement extended to surfaces).
|
||||
4. The resolved path component MUST pass the same path-containment check
|
||||
`server/serve.js` already applies to the primary exhibit path (confined to
|
||||
`public/`, `src/`, or `test-fixtures/`, no `..` escape).
|
||||
5. `url` values that are absolute (`https://…`), protocol-relative
|
||||
(`//…`), or resolve cross-origin or outside the served roots MUST be
|
||||
rejected at step 3 or 4 as a malformed individual entry (Section 6.1) —
|
||||
they do not invalidate the rest of the array.
|
||||
|
||||
This makes explicit what was previously only stated as a prohibition:
|
||||
surface URLs are resolved on exactly the same basis, and validated by
|
||||
exactly the same containment logic, as the primary exhibit document is
|
||||
today. No new resolution concept is introduced.
|
||||
|
||||
---
|
||||
|
||||
## 7. State Synchronization Model
|
||||
|
||||
This is the section the whole feature stands or falls on, per the Step 6
|
||||
brief's critical architectural rule.
|
||||
|
||||
### 7.1 One session, one state, many views
|
||||
|
||||
A surface does **not** open its own contract session. All surfaces of one
|
||||
exhibit instance share the exhibit's single `sessionId`, single
|
||||
`stateRevision` history, and single event `sequence` stream, exactly as
|
||||
defined in Contract §5, §14, and §16 today. Multi-surface support adds zero
|
||||
new session or state-authority concepts — it only adds more places that
|
||||
*read* the one existing truth and more places a native interaction can
|
||||
*originate from*.
|
||||
|
||||
### 7.2 The generic surface-attachment mechanism
|
||||
|
||||
Revision 1 left this as an unresolved implementation choice. This revision
|
||||
defines a concrete, generic pattern every multi-surface exhibit is expected
|
||||
to implement, independent of how many documents or windows are involved.
|
||||
|
||||
**The pattern has two required properties, one required sequence, and two
|
||||
permitted transport shapes.**
|
||||
|
||||
**Required properties:**
|
||||
|
||||
- Exactly one Exhibit State Core exists per exhibit instance (Section 2).
|
||||
Every surface attaches to that one Core. No surface ever instantiates a
|
||||
second Core, a cached copy of state, or an independent computation of
|
||||
state.
|
||||
- Attachment is a runtime relationship between a surface document and the
|
||||
Core, entirely internal to the exhibit. It is not part of the XZBT Exhibit
|
||||
Contract and does not appear in Contract 5.3 — the contract only needs to
|
||||
know that surfaces exist and how NGN opens one (Section 6). This mirrors
|
||||
Contract §4's transport independence: the contract defines semantics, not
|
||||
one mandatory internal wiring.
|
||||
|
||||
**Required attachment sequence** (identical regardless of transport shape
|
||||
below):
|
||||
|
||||
1. The surface document loads and requests attachment from the Core.
|
||||
2. The Core responds with the current authoritative state snapshot — sourced
|
||||
from the same internal read path the contract adapter itself already uses
|
||||
to answer `state.get`, not a second computation — and the current
|
||||
`stateRevision`.
|
||||
3. The Core registers the surface as a subscriber for ongoing
|
||||
`state.changed` / `selection.changed` / relevant event notifications.
|
||||
4. Any native interaction originating in the surface calls the Core's one
|
||||
canonical mutation entry point (Section 7.4) — the same entry point a
|
||||
contract `set`/`invoke` from NGN already uses.
|
||||
5. On detach (surface closed), the Core drops the subscription. No state is
|
||||
held by the surface, so nothing needs to be reconciled or discarded.
|
||||
|
||||
**Two permitted transport shapes for attachment** (an exhibit MUST implement
|
||||
at least one; which one is an exhibit implementation decision, not a
|
||||
contract requirement):
|
||||
|
||||
- **In-process attachment.** The surface is a view rendered within the same
|
||||
JavaScript runtime/document that owns the Core — e.g. a single-page
|
||||
exhibit that swaps visible panels, or a document that embeds other
|
||||
surfaces as same-document components. Attachment is a direct in-memory
|
||||
subscription; no cross-document transport is involved.
|
||||
- **Cross-document attachment.** The surface runs in a separate browsing
|
||||
context (its own window/tab, its own `url` per Section 6.3) and attaches
|
||||
to the Core over a same-origin, exhibit-internal channel — e.g.
|
||||
`BroadcastChannel`, a `SharedWorker`, or same-origin `postMessage` to
|
||||
whichever document hosts the Core (typically the primary surface's
|
||||
document). This channel is entirely separate from, and must not be
|
||||
confused with, the NGN↔exhibit contract transport (Contract §4); it
|
||||
carries no contract envelope and is never observed by NGN.
|
||||
|
||||
The Museum Gallery reference exhibit (Phase 6.2) is required to implement and
|
||||
prove one concrete instance of this pattern — expected to be cross-document
|
||||
attachment, since its surfaces are separate documents/windows opened by NGN
|
||||
(Section 8) — and to document it as the reusable template Phase 6.7 adapts
|
||||
for SciFi-XZBT's Observation surface. Phase 6.2 MAY also demonstrate
|
||||
in-process attachment if useful for the control surface specifically, but at
|
||||
least one cross-document instance must exist to prove the multi-window case
|
||||
the brief requires.
|
||||
|
||||
### 7.3 How a surface receives ongoing state changes
|
||||
|
||||
Covered by the attachment sequence in 7.2 (steps 2–3): a surface never
|
||||
polls; it subscribes at attachment time and receives the same
|
||||
`state.changed`/`selection.changed` notifications the Core already emits
|
||||
internally, over whichever transport shape (in-process or cross-document)
|
||||
its attachment uses.
|
||||
|
||||
### 7.4 Native interaction routing (the canonical mutation path)
|
||||
|
||||
A user acting on any surface — clicking a control on the "Artifact Display,"
|
||||
adjusting a slider on the "Information Wall" — MUST result in exactly the
|
||||
same internal mutation call the exhibit's existing single-surface UI would
|
||||
make, which is the same call the contract adapter uses to satisfy an
|
||||
NGN-issued `set`/`invoke`. There must be exactly **one** function/method per
|
||||
mutable target that changes exhibit state, on the Exhibit State Core itself;
|
||||
every UI element on every surface, and every contract `set`/`invoke` from
|
||||
NGN, calls into that same function. This is not new architecture — it is the
|
||||
existing single-authoritative-state requirement from Contract §14, restated
|
||||
for the case where more than one DOM document can trigger it.
|
||||
|
||||
Concretely for the reference exhibit: the same `contract-adapter.js` /
|
||||
`exhibit.js` core already used by the Step 5 reference fixtures (see
|
||||
`test-fixtures/reference-exhibits/shared/contract-core.js`) is the pattern to
|
||||
extend into a proper Exhibit State Core, not replace. A surface's document is
|
||||
a thin presentation layer attached to that core; it must not contain a
|
||||
second, parallel copy of state or mutation logic.
|
||||
|
||||
### 7.5 What "no duplicate authoritative state" rules out
|
||||
|
||||
Explicitly disallowed by this model, matching the brief's stated
|
||||
anti-pattern:
|
||||
|
||||
- launching a separate exhibit instance (a second `hello`/session with its
|
||||
own `stateRevision` history) per surface;
|
||||
- a surface document that keeps its own local copy of state and only
|
||||
periodically reconciles it, rather than attaching per Section 7.2;
|
||||
- a surface whose native interactions call an exhibit-internal function that
|
||||
bypasses the Core's one canonical mutation entry point;
|
||||
- two surfaces attaching to two different Core instances of the same
|
||||
exhibit, however attachment is transported.
|
||||
|
||||
---
|
||||
|
||||
## 8. Lifecycle
|
||||
|
||||
A surface descriptor's existence in `surfaces` only establishes that the
|
||||
surface is **available**. Whether it is currently shown is host-side,
|
||||
per-surface, per-session bookkeeping — not exhibit state, and not persisted
|
||||
across a full exhibit reload.
|
||||
|
||||
| State | Meaning | Who tracks it |
|
||||
| --- | --- | --- |
|
||||
| `available` | The surface is discoverable via `describe` but no host action has been taken on it. | Derived from `surfaces` array; not separately tracked. |
|
||||
| `opened` | NGN has loaded the surface's document (Step 6: in a local browser window/view), which then performs the attachment sequence in Section 7.2. | NGN, per session. |
|
||||
| `visible` | The opened surface's window/view currently has visual presence (not minimized/backgrounded). Best-effort; browsers do not always expose this reliably. | NGN, best-effort. |
|
||||
| `hidden` | Opened but not currently visible (backgrounded, minimized). | NGN, best-effort. |
|
||||
| `closed` | NGN has torn down the surface's window/view; the surface's document detaches from the Core (Section 7.2, step 5). The surface remains `available` for reopening. | NGN, per session. |
|
||||
| `unavailable` | A `requires` capability (Section 5.2) is not `ready`. The surface stays listed but SHOULD be presented as non-operable. | NGN, derived from `capabilities`. |
|
||||
|
||||
Lifecycle transitions are entirely local bookkeeping on the NGN side about
|
||||
*presentation*, not contract-visible exhibit state. Opening, closing, or
|
||||
reopening a surface MUST NOT itself cause a `set`/`invoke`, MUST NOT change
|
||||
`stateRevision`, and MUST NOT be recorded as an `action.executed` event. It
|
||||
may optionally be logged as an NGN-local diagnostic entry, exactly as
|
||||
`src/host.js` already logs connection lifecycle events today.
|
||||
|
||||
**Reopen behavior.** When a previously-closed surface is opened again, its
|
||||
document performs the same attachment sequence as a first open (Section
|
||||
7.2). Because state lives in the Core, not in the surface, a reopened
|
||||
surface reflects current state automatically — there is nothing to "restore"
|
||||
at the surface level.
|
||||
|
||||
---
|
||||
|
||||
## 9. Session/Revision Implications
|
||||
|
||||
- `registryRevision` covers `surfaces` in addition to `targets`/`capabilities`.
|
||||
No separate surface-registry counter is introduced. A `registry.changed`
|
||||
event already means "re-run `describe`"; that now also refreshes the known
|
||||
surface set.
|
||||
- `stateRevision` and event `sequence` are entirely unaffected by
|
||||
multi-surface support — they remain properties of the one session, per
|
||||
Contract §14 and §16, regardless of how many surfaces are open or how they
|
||||
attach to the Core.
|
||||
- **Reconnect.** On session loss and reconnect (existing `src/connection.js`
|
||||
behavior), NGN re-runs the full negotiation and rediscovery, which
|
||||
naturally re-obtains the current `surfaces` array. Any currently-open
|
||||
surface windows are host-side presentation state and are NOT automatically
|
||||
torn down by a reconnect; whether they should be is a Phase 6.5/6.6
|
||||
implementation question (recommend: leave open, let the surface's own
|
||||
document's attachment to the Core recover independently, rather than NGN
|
||||
force-closing surface windows on transient session loss).
|
||||
- **Are surface definitions static per registry revision?** Yes. Within one
|
||||
`registryRevision`, the `surfaces` array is a stable snapshot, exactly like
|
||||
`targets`. An exhibit that wants to change its surface set (add/remove a
|
||||
surface) MUST bump `registryRevision` and emit `registry.changed`, per the
|
||||
existing §23 mechanism — no new mechanism needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Standalone Behavior
|
||||
|
||||
A standalone exhibit — opened directly in a browser tab, with no NGN attached
|
||||
— MUST continue to work with zero dependency on this model, per Contract §2's
|
||||
governing rule. This revision relaxes the prior draft's requirement that
|
||||
*every* surface be independently openable without any other document present,
|
||||
because that conflicted with the cross-document attachment shape defined in
|
||||
Section 7.2 (a surface attaching via `postMessage`/`BroadcastChannel` to
|
||||
whichever document hosts the Core may genuinely need that document to already
|
||||
be open).
|
||||
|
||||
The precise, tightened requirement:
|
||||
|
||||
- **The primary surface** (Section 6.2) MUST remain fully, unconditionally
|
||||
standalone-capable: a person navigating directly to the exhibit's normal
|
||||
entry point (its existing `index.html`) gets a complete, independently
|
||||
usable experience with zero dependency on NGN, on any other surface being
|
||||
open, or on any attachment sequence having occurred. This is the exhibit's
|
||||
existing standalone guarantee, entirely unchanged by Step 6. Nothing about
|
||||
standalone use of the primary surface requires a person to know surfaces
|
||||
exist.
|
||||
- **Non-primary surfaces** SHOULD remain directly openable without NGN
|
||||
whenever the exhibit's chosen attachment mechanism allows it:
|
||||
- an exhibit using **in-process attachment**, or a **cross-document**
|
||||
mechanism where the surface can bootstrap its own Core connection
|
||||
on demand (e.g. attaching to a `SharedWorker` that starts itself), can
|
||||
and should support a non-primary surface being opened first, with no
|
||||
other document present — matching how SciFi-XZBT's existing Observation
|
||||
view already behaves today;
|
||||
- an exhibit using **cross-document attachment that depends on the primary
|
||||
document already being open** (e.g. plain `postMessage` to a specific
|
||||
existing window) MAY require the primary surface to be open first for a
|
||||
non-primary surface to attach successfully. This is an accepted
|
||||
consequence of an exhibit's internal transport choice (Section 7.2), not
|
||||
a contract violation, and MUST NOT be worked around by having the
|
||||
non-primary surface silently start a second Core.
|
||||
- In either case, this is a same-origin, exhibit-internal dependency
|
||||
between the exhibit's own documents — never a dependency on NGN. A
|
||||
non-primary surface opened without NGN and without its dependency met
|
||||
SHOULD degrade to a clear "waiting for exhibit" state rather than
|
||||
silently diverging into independent state.
|
||||
- Nothing about surface discovery itself requires network access, a host, or
|
||||
any NGN-specific code inside the exhibit. `surfaces` is just more
|
||||
`describe` metadata; an exhibit run standalone never calls `describe` on
|
||||
itself.
|
||||
|
||||
---
|
||||
|
||||
## 11. Host (NGN) Behavior
|
||||
|
||||
- On `describe`, NGN parses `surfaces` alongside `targets`/`capabilities`
|
||||
using the validation discipline in Sections 6.1–6.2: tolerate individually
|
||||
malformed entries, but reject the whole array (falling back to implicit
|
||||
single-primary behavior) on a `primary` structural violation.
|
||||
- NGN's admin/control UI (`src/ui.js`) gains a generic, descriptor-driven
|
||||
surface list — rendered from `label`/`role`/`primary`, never from any
|
||||
hard-coded surface name. This is the direct analog of the existing generic
|
||||
target-rendering rule in the NGN Implementation Plan §8; no exhibit
|
||||
vocabulary may leak into `src/`.
|
||||
- Opening a surface (Phase 6.4) means NGN loads that surface's resolved `url`
|
||||
(Section 6.3) into a new local browser window/view, same-origin, using the
|
||||
same hosting mechanism `server/serve.js` already provides for the primary
|
||||
exhibit frame today.
|
||||
- NGN's control UI and any number of open surface windows coexist; closing
|
||||
or reloading a surface window never tears down the underlying exhibit
|
||||
session, and disconnecting the exhibit session (existing Disconnect
|
||||
behavior) SHOULD close any surface windows NGN opened, since they have
|
||||
nothing left to observe.
|
||||
- A single-surface exhibit (ordinary case, including every Step 5 reference
|
||||
fixture, and every exhibit using discovery form 1 or 2 from Section 6.2)
|
||||
continues to work with **zero NGN behavior change**: one implicit or
|
||||
declared primary surface, opened automatically into the one frame NGN
|
||||
already loads today, with no surface picker UI shown when there is nothing
|
||||
to pick between.
|
||||
|
||||
---
|
||||
|
||||
## 12. Non-Goals (Explicitly Out of Scope for Step 6)
|
||||
|
||||
Restating the brief's guardrails as binding scope boundaries for this
|
||||
document and everything built from it:
|
||||
|
||||
- Casting, Chromecast, Google TV, Android clients, remote/network display
|
||||
endpoints — all Step 7.
|
||||
- Any encoding of a physical or network display target inside a surface
|
||||
descriptor.
|
||||
- MIDI, MCP, webhooks, scenarios, recording, telemetry acquisition,
|
||||
multi-exhibit orchestration, databases, cloud services, user accounts.
|
||||
- Any redesign of Contract 5.2 behavior unrelated to surfaces.
|
||||
- Any surface concept that requires per-surface authentication/authorization
|
||||
beyond the existing same-origin session trust boundary (§25) — internet-
|
||||
facing auth remains NGN's problem generally, unchanged by this feature.
|
||||
- Standardizing the cross-document attachment transport (`BroadcastChannel`
|
||||
vs. `SharedWorker` vs. `postMessage`) as a contract-level choice — Section
|
||||
7.2 deliberately leaves this to the exhibit.
|
||||
|
||||
---
|
||||
|
||||
## 13. Security / Origin Considerations
|
||||
|
||||
- Every surface `url` resolves same-origin per the algorithm in Section 6.3,
|
||||
a direct extension of Contract §25's same-origin `postMessage` validation
|
||||
requirement (`event.origin` and `event.source` checks) to the new case of
|
||||
multiple documents. A surface document is exactly as trusted, and exactly
|
||||
as constrained, as the exhibit's primary document already is.
|
||||
- The exhibit-internal attachment channel (Section 7.2) — `BroadcastChannel`,
|
||||
`SharedWorker`, or same-origin `postMessage` — inherits the same
|
||||
same-origin trust boundary. It carries no contract envelope and MUST NOT be
|
||||
reachable from any other origin; this is ordinary same-origin browser
|
||||
isolation, not a new security mechanism.
|
||||
- No surface descriptor field may contain executable markup, a remote origin,
|
||||
or a URL scheme other than a relative path/fragment into the served
|
||||
exhibit. This mirrors the existing safe-text-injection rule (§20) applied
|
||||
to the new descriptor type.
|
||||
- NGN's server (`server/serve.js`) already restricts served paths to
|
||||
`public/`, `src/`, and `test-fixtures/` and denies path escapes; surface
|
||||
`url` values are subject to the same path-containment validation (Section
|
||||
6.3, step 4) before the host will load them — a malformed or escaping
|
||||
`url` is a rejected individual entry (Section 6.1), not a followed one.
|
||||
- Multiple surface documents observing one session is a same-origin,
|
||||
same-trust-boundary fan-out, not a new external trust relationship. No new
|
||||
authentication concept is introduced in Step 6.
|
||||
|
||||
---
|
||||
|
||||
## 14. Contract-Version Decision — APPROVED, NOT YET MINTED
|
||||
|
||||
### 14.1 Decision
|
||||
|
||||
**The owner has approved Contract 5.3 as the intended version for
|
||||
multi-surface presentation.** This is no longer a pending recommendation; it
|
||||
is the settled architectural direction this document and Phase 6.2 onward
|
||||
are written against.
|
||||
|
||||
**What remains explicitly deferred, per this instruction, until separately
|
||||
authorized:**
|
||||
|
||||
- minting the actual versioned file
|
||||
(`docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md`);
|
||||
- any change to `contract.major`/`contract.minor` reported by real
|
||||
`describe.result` responses;
|
||||
- any edit to the existing, currently-normative
|
||||
`XZBT-Exhibit-Contract-Specification-v5.2.md` document.
|
||||
|
||||
This document (6.1) continues to illustrate `describe.result` examples as
|
||||
`"xzbt": "5.3"` / `contract: {major: 5, minor: 3}` (Section 6) purely as the
|
||||
**target shape** the eventual 5.3 document will formalize — this is
|
||||
illustrative design content, not a claim that the file or the reported
|
||||
version exists yet.
|
||||
|
||||
### 14.2 Why 5.3 (retained from Revision 1, unchanged)
|
||||
|
||||
`describe.result` gains a new top-level array, `surfaces`. This is additive
|
||||
in the sense that an unaware consumer can ignore an unknown field — but
|
||||
Contract §28.2 is explicit: *"Backward-compatible additions may increase the
|
||||
minor version."* A new, normatively defined top-level response field with its
|
||||
own validation rules (Section 6), its own descriptor grammar (Section 5), and
|
||||
its own required/optional field contract is exactly the category of change
|
||||
§28.2 describes, not a same-version clarification. Step 5's `arguments` field
|
||||
addition (Contract §9.5) was explicitly scoped as *"clarifies Contract 5.2;
|
||||
it does not introduce Contract 5.3"* — because it formalized behavior that
|
||||
was already implicitly required (impulse arguments had to be validated
|
||||
somehow) without adding a new discoverable construct. Surfaces are different:
|
||||
nothing in 5.2 implies surfaces exist, and no 5.2-conformant exhibit or host
|
||||
is required to know what `surfaces` means.
|
||||
|
||||
Contract major stays 5 — this is additive, not breaking. An exhibit that
|
||||
only implements 5.2 behavior remains fully conformant; it simply never
|
||||
emits `surfaces` (Section 6.2, form 1), and a 5.3-aware host tolerates that
|
||||
identically to an exhibit that emits `surfaces: []` (form 2) or a single
|
||||
`primary` entry (form 3). A 5.3-aware host MUST remain fully capable of
|
||||
driving a 5.2-only exhibit exactly as it does today.
|
||||
|
||||
### 14.3 What still requires owner sign-off before the file is minted
|
||||
|
||||
- Timing: whether to mint the versioned file once this 6.1 design is
|
||||
approved, or defer the formal document bump until the surface model has
|
||||
additionally been proven out end-to-end in the reference exhibit (Phases
|
||||
6.2–6.6).
|
||||
- Any wording changes to the *existing* 5.2 document (this proposal
|
||||
recommends none — 5.3 is additive-only — but that remains a decision to
|
||||
reconfirm at minting time).
|
||||
|
||||
---
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
Revision 1's Open Question 1 (empty vs. absent `surfaces`) is now resolved by
|
||||
Section 6.2 and removed from this list.
|
||||
|
||||
1. Should `role` (Section 5.2) start as free text or a small closed
|
||||
enumeration? This proposal leaves it free text for 6.1–6.6 and defers
|
||||
tightening it until real reference-exhibit and SciFi-XZBT usage exists to
|
||||
generalize from.
|
||||
2. Exact behavior when a currently-open surface's descriptor disappears from
|
||||
a new `describe` result (e.g. `registryRevision` bump removes a surface
|
||||
that is currently open in a window) — recommend NGN closes the window and
|
||||
surfaces a diagnostic, but this needs to be proven in Phase 6.5/6.6, not
|
||||
just asserted here.
|
||||
3. Whether `requires` on a surface (Section 5.2) should block *opening* the
|
||||
surface outright, or only annotate it as degraded while still allowing
|
||||
open (consistent with how a target with an unavailable capability remains
|
||||
discoverable per §9). This proposal recommends the latter, for
|
||||
consistency, but flags it for confirmation during Phase 6.3.
|
||||
4. Whether the generic attachment mechanism (Section 7.2) should eventually
|
||||
be *documented* (not contractually mandated) as a recommended pattern in
|
||||
the Authoring Guide, once the reference exhibit and SciFi-XZBT adaptation
|
||||
have both proven it out — so future exhibit authors aren't reinventing
|
||||
attachment transport choices from scratch. Recommend deferring this
|
||||
documentation question to after Phase 6.8.
|
||||
|
||||
---
|
||||
|
||||
## 16. Acceptance Criteria for 6.1 (Revision 2)
|
||||
|
||||
This document satisfies Phase 6.1 when:
|
||||
|
||||
- [x] It defines what a presentation surface is and is not (Sections 2–3).
|
||||
- [x] It defines the surface descriptor, required and optional fields
|
||||
(Section 5), with surface IDs conforming exactly to the existing
|
||||
canonical target-ID grammar (Section 5.1) — no carve-out.
|
||||
- [x] It defines discovery as an extension to `describe` governed by
|
||||
`registryRevision` (Sections 6, 9), with a tightened, unambiguous
|
||||
`primary`/backward-compatibility invariant covering exactly three
|
||||
recognized forms of `surfaces` (Section 6.2).
|
||||
- [x] It defines an explicit, generic surface-attachment mechanism — required
|
||||
properties, a required attachment sequence, and two permitted
|
||||
transport shapes — that any exhibit implementation can follow without
|
||||
inventing its own protocol (Section 7.2).
|
||||
- [x] It defines an explicit URL-resolution algorithm for the surface `url`
|
||||
field, including same-origin and path-containment enforcement (Section
|
||||
6.3).
|
||||
- [x] It defines a state-synchronization model with a single authoritative
|
||||
state (the Exhibit State Core) shared by all surfaces, and explicitly
|
||||
rules out per-surface duplicated state (Section 7).
|
||||
- [x] It defines the surface lifecycle (Section 8).
|
||||
- [x] It defines ownership across exhibit / NGN / transport / display
|
||||
endpoint (Section 4), explicitly deferring display endpoints to Step 7.
|
||||
- [x] It defines standalone-exhibit compatibility with a precise,
|
||||
non-conflicting requirement split between the primary surface
|
||||
(unconditional) and non-primary surfaces (best-effort, attachment-
|
||||
mechanism-dependent) (Section 10).
|
||||
- [x] It defines security/origin assumptions, extended to the attachment
|
||||
channel (Section 13).
|
||||
- [x] It records the owner-approved Contract 5.3 decision, with the file
|
||||
mint and contract-document edits still explicitly deferred (Section
|
||||
14).
|
||||
- [x] It lists genuine remaining open questions rather than inventing answers
|
||||
where none are warranted (Section 15).
|
||||
- [x] It does not begin implementation, does not create the Contract 5.3
|
||||
file, and does not modify existing source or contract documents.
|
||||
|
||||
**Next step, upon final owner approval of this revision:** mint the
|
||||
versioned Contract 5.3 file per Section 14.3's timing decision, then begin
|
||||
Phase 6.2 — build the Museum Gallery reference exhibit proving this model.
|
||||
Neither has begun as part of this document.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,395 @@
|
||||
# Museum Gallery — Step 6.2 Verification
|
||||
|
||||
**Status: STEP 6.2 COMPLETE — REFERENCE MULTI-SURFACE MODEL PROVEN.** The
|
||||
real-browser harness (Section K) has now been executed and passed 13/13
|
||||
required proof points (16/16 including 3 additional harness self-checks),
|
||||
repeated across 6 consecutive runs with zero flakes.
|
||||
|
||||
## A. Purpose
|
||||
|
||||
This document records the verification evidence for Phase 6.2 of Step 6:
|
||||
the Museum Gallery multi-surface reference exhibit, built against Contract
|
||||
5.3 (`docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md`) and the
|
||||
Step 6.1 architecture (`docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md`,
|
||||
Revision 2).
|
||||
|
||||
## B. Architecture summary
|
||||
|
||||
One Museum Gallery exhibit instance exposes three presentation surfaces —
|
||||
`surface.control` (primary), `surface.artifact`, `surface.info-wall` — all
|
||||
views onto one Exhibit State Core, constructed exactly once, only by the
|
||||
primary surface's document (`control.html`). Non-primary surfaces
|
||||
(`artifact.html`, `info-wall.html`) never load the Core-constructing files
|
||||
(`contract-core.js`, `exhibit.js`, `contract-adapter.js`) at all; they load
|
||||
only the exhibit-internal attachment bus (`surface-bus.js`) and attach to the
|
||||
Core that `control.html` owns. This is structural, not just documented
|
||||
convention: those two documents are incapable of constructing a second Core
|
||||
because the constructor (`window.MuseumGalleryContract.create`) is simply
|
||||
undefined in their execution context — proven directly in
|
||||
`tests/museum-gallery.test.js` ("Exactly one Exhibit State Core exists").
|
||||
|
||||
## C. Chosen internal attachment transport
|
||||
|
||||
Same-origin `BroadcastChannel`, channel name `xzbt-museum-gallery-core-v1`,
|
||||
with `control.html` as the sole identified owner. Full rationale and message
|
||||
shapes are documented in
|
||||
`test-fixtures/reference-exhibits/museum-gallery/README.md`. This transport
|
||||
is exhibit-internal per Contract 5.3 §31.7/§31.9 — it is not part of the
|
||||
XZBT wire protocol, carries no contract envelope, and Contract 5.3 was not
|
||||
modified to encode it.
|
||||
|
||||
## D. Surface list
|
||||
|
||||
| id | label | primary | url |
|
||||
| --- | --- | --- | --- |
|
||||
| `surface.control` | Control Room | `true` | `control.html` |
|
||||
| `surface.artifact` | Artifact Display | `false` | `artifact.html` |
|
||||
| `surface.info-wall` | Information Wall | `false` | `info-wall.html` |
|
||||
|
||||
All three are discovered from a live `core.describe().surfaces` call in
|
||||
`tests/museum-gallery.test.js`'s first test, which also asserts the
|
||||
canonical dotted-ID grammar, the single-`primary` invariant, and
|
||||
relative-only `url` values.
|
||||
|
||||
## E. Shared state
|
||||
|
||||
`artifact.selected` (selection: `the-orrery` / `star-map` / `meteorite`),
|
||||
`lighting.level` (range 0–1), `rotation.speed` (range 0–2),
|
||||
`labels.enabled` (boolean state), plus one impulse, `action.spotlight-flash`,
|
||||
used to prove `action.executed` propagation across surfaces.
|
||||
|
||||
## F. Proof of one authoritative Core
|
||||
|
||||
Three lines of evidence:
|
||||
|
||||
1. **Structural**: non-primary surface test contexts never load the files
|
||||
that could construct a Core (`tests/museum-gallery.test.js`, "Exactly one
|
||||
Exhibit State Core exists" and the artifact/info-wall boot scripts
|
||||
themselves — see their file headers in
|
||||
`test-fixtures/reference-exhibits/museum-gallery/`).
|
||||
2. **Behavioral**: every synchronization test drives state changes from
|
||||
both the "primary" (direct `core.applyMutation`/`invokeAction` calls,
|
||||
standing in for the Control Room's own controls) and from a non-primary
|
||||
surface's `link.mutate(...)` call, and asserts the *other* surface(s)
|
||||
converge on the identical value with the identical `stateRevision` —
|
||||
there is no code path in this exhibit that could produce disagreement,
|
||||
because there is only one place state is ever written.
|
||||
3. **Sequence**: `core.eventLog()` sequence numbers are asserted strictly
|
||||
monotonic and non-duplicated across mutations that originated from
|
||||
different "surfaces" in the same test run — one stream, not one per
|
||||
surface.
|
||||
|
||||
## G. Reference exhibit implementation
|
||||
|
||||
Files added under `test-fixtures/reference-exhibits/museum-gallery/`:
|
||||
`exhibit.js` (domain model), `contract-adapter.js` (Contract 5.3 wiring,
|
||||
including the `SurfaceCatalog`), `surface-bus.js` (attachment transport),
|
||||
`control.html`/`control.boot.js` (primary), `artifact.html`/
|
||||
`artifact.boot.js` and `info-wall.html`/`info-wall.boot.js` (non-primary),
|
||||
`style.css`, `index.html` (redirect to `control.html`), `README.md`,
|
||||
`verification.html` (browser harness — see Section K).
|
||||
|
||||
## H. NGN discovery implementation
|
||||
|
||||
**Not built in Phase 6.2, by design.** The Step 6.2 brief explicitly
|
||||
excludes NGN generic surface discovery, a surface picker, and open/close
|
||||
window controls from this phase (those are Phase 6.3). Nothing in
|
||||
`src/` was touched.
|
||||
|
||||
## I. Local rendering implementation
|
||||
|
||||
Also explicitly out of scope for 6.2 (Phase 6.4). `verification.html` opens
|
||||
the three surfaces as iframes purely as a **test harness** for this
|
||||
document's own evidence gathering, not as NGN product behavior.
|
||||
|
||||
## J. Shared-state synchronization — automated test results
|
||||
|
||||
`tests/museum-gallery.test.js`, run via `node --test tests/museum-gallery.test.js`:
|
||||
|
||||
```
|
||||
1..16
|
||||
# tests 16
|
||||
# pass 16
|
||||
# fail 0
|
||||
```
|
||||
|
||||
Full suite together with the pre-existing tests (`node --test 'tests/*.test.js'`):
|
||||
|
||||
```
|
||||
1..18
|
||||
# tests 18
|
||||
# pass 18
|
||||
# fail 0
|
||||
```
|
||||
|
||||
The 16 Museum Gallery tests cover proof points 1–13 from the Step 6.2 brief
|
||||
directly:
|
||||
|
||||
1. Contract 5.3 `describe` includes `surfaces` — covered.
|
||||
2. exactly one primary surface — covered.
|
||||
3. surface IDs validate — covered (both the describe-level test and four
|
||||
dedicated `SurfaceCatalog` unit tests covering the Part A2 validation
|
||||
order: an individually-invalid entry discarded before the primary
|
||||
invariant is evaluated, zero-primary rejection, multiple-primary
|
||||
rejection, and all-entries-discarded-is-absent-not-malformed).
|
||||
4. surface URLs validate — covered, including a dedicated test for
|
||||
absolute/protocol-relative URLs being discarded.
|
||||
5. one authoritative Core — covered (Section F).
|
||||
6. primary → non-primary synchronization — covered.
|
||||
7. non-primary → primary and non-primary → other non-primary — covered in
|
||||
one test, asserting both directions from a single surface-originated
|
||||
mutation.
|
||||
8. `stateRevision` increments correctly — covered, including the "increments
|
||||
exactly once" assertion on the surface-originated mutation test.
|
||||
9. one event-sequence stream — covered.
|
||||
10. close/detach does not mutate state — covered (asserts `stateRevision`
|
||||
and `eventLog().length` are unchanged immediately after detach).
|
||||
11. reopen/reattach gets current state — covered, both as its own test and
|
||||
as part of the detach test (attach → mutate → detach → mutate again →
|
||||
reattach → assert the reattached surface sees the latest value, not a
|
||||
stale one).
|
||||
12. no independent per-surface state — covered directly (two surfaces
|
||||
attached simultaneously are asserted to receive byte-identical event
|
||||
sequences) and structurally (Section F).
|
||||
13. primary still works standalone — covered: a dedicated test drives the
|
||||
Core through `stateSnapshot`/`applyMutation`/`invokeAction` with zero
|
||||
surface-bus or NGN involvement at all.
|
||||
|
||||
**What this test suite genuinely proves, precisely stated**: these tests use
|
||||
Node's own global `BroadcastChannel` implementation (the same message-passing
|
||||
spec surface the DOM implementation exposes, keyed by channel name,
|
||||
process-wide) across independent `vm` contexts that do not share JavaScript
|
||||
realm state — this is a real, if not browser-hosted, proof of the
|
||||
cross-document attachment sequence, not a mock of it. What it does **not**
|
||||
prove is browser-specific behavior: real separate windows/tabs, real
|
||||
same-origin-over-HTTP enforcement, real page-unload `detach()` firing, or
|
||||
visual rendering. That gap is Section K/L below.
|
||||
|
||||
## K. Reference exhibit browser verification
|
||||
|
||||
**Executed this session — real browser, real HTTP origin, real
|
||||
cross-document `BroadcastChannel`. Result: 13/13 required proof points
|
||||
passing (16/16 including 3 additional harness self-checks), on every one of
|
||||
6 consecutive runs.**
|
||||
|
||||
`test-fixtures/reference-exhibits/museum-gallery/verification.html` opens
|
||||
all three surfaces (`control.html`, `artifact.html`, `info-wall.html`) as
|
||||
three separate same-origin iframe documents and drives the 13 proof points
|
||||
listed in Section J directly against real DOM state and a real,
|
||||
non-mocked `BroadcastChannel`, logging PASS/FAIL per check plus a
|
||||
pass/total summary.
|
||||
|
||||
**Why this session, not the user's machine.** The device bridge's file
|
||||
mount (`device_bash`) has remained unreachable all session ("A Windows
|
||||
update released September 8 prevents Claude's workspace from reaching your
|
||||
files"), so `npm start` could not be run on the user's machine as
|
||||
originally planned. Rather than leave the browser-verification requirement
|
||||
unsatisfied, the exhibit's already-staged files (present in this session's
|
||||
own working copy from building Phase 6.2) were served over a real HTTP
|
||||
origin (`http://127.0.0.1:4173/...`) by a throwaway static file server
|
||||
started in this session's cloud container, and driven with a genuine
|
||||
Chromium instance (Playwright, headless) — not a headless DOM shim, not
|
||||
`file://`, and not Node's `vm`-context proof from Section J. This satisfies
|
||||
the same same-origin requirement `npm start` would have: a real HTTP
|
||||
origin, not an opaque per-navigation `file://` origin. The static server
|
||||
and the Playwright driver script are test infrastructure only; neither is
|
||||
part of the Museum Gallery deliverable and neither was added to the repo.
|
||||
|
||||
**Harness hardening applied this pass.** The harness previously used fixed
|
||||
sleeps (`await wait(300)`, `wait(200)`, `wait(250)`, …) standing in for
|
||||
attachment and propagation delays. Every one of those was replaced with
|
||||
condition-based polling against the actual signal being waited on:
|
||||
`isAttached()` on both non-primary surface links before proceeding, DOM
|
||||
content matching the expected post-mutation value (for the primary→non-primary
|
||||
and non-primary→primary/non-primary checks), and `isAttached()` again after
|
||||
the Information Wall's reopen. A failed poll now dumps diagnostics (the
|
||||
Core's current value for the target, the surface's DOM value, the current
|
||||
`stateRevision`, and the 5 most recent events) instead of a bare FAIL line.
|
||||
Only `verification.html` (the test harness) was changed — no change was
|
||||
made to `surface-bus.js`, `contract-core.js`, or any Museum Gallery boot
|
||||
script, since no run of the hardened harness ever demonstrated a defect in
|
||||
them (see below).
|
||||
|
||||
**One flaky result during hardening, and what it showed.** The very first
|
||||
run against the newly-hardened harness (before any polling logic existed)
|
||||
returned 12/16, with `artifactLink.isAttached()` still `false` at the
|
||||
3-second poll deadline and two downstream checks failing in consequence;
|
||||
the diagnostic dump showed the Core's own state (`rotation.speed: 1.7`,
|
||||
`stateRevision: 1`) was already correct at that moment — only the
|
||||
`artifact.html` iframe's attachment hadn't yet been observed by the
|
||||
poll. Every subsequent run (6 consecutive, described above) passed 16/16
|
||||
with the identical unmodified exhibit code, which is inconsistent with a
|
||||
code defect and consistent with a one-time cold-start artifact (first
|
||||
Chromium launch, first page load, first `BroadcastChannel` construction in
|
||||
that process). No exhibit or surface-bus code was changed in response,
|
||||
per the instruction to leave the state architecture and surface bus alone
|
||||
absent a deterministic proof of a defect — six identical, clean runs is
|
||||
that determinism check, and it did not implicate the exhibit.
|
||||
|
||||
**One benign console error observed, not a defect.** On every run, the
|
||||
Information Wall's reopen step (proof point 11: the iframe's `src` is set
|
||||
back to `info-wall.html`, forcing a real navigation) produces a harmless
|
||||
`pageerror` from the *previous* document's `beforeunload` handler:
|
||||
`info-wall.boot.js` calls `link.detach()`, which calls
|
||||
`channel.postMessage(...)`, but the browser has already begun invalidating
|
||||
that document's `BroadcastChannel` by the time `beforeunload` runs, so the
|
||||
`postMessage` throws `InvalidStateError: Channel is closed`. This is a
|
||||
teardown-time exception in code that no longer affects any observable
|
||||
state (the document is already being discarded) — it does not fail any of
|
||||
the 13 proof points in any of the 6 runs, and `surface-bus.js`'s own
|
||||
comment already documents that detach is bookkeeping-only ("no state is
|
||||
held by the surface, so nothing reconciles"). It is recorded here as an
|
||||
observed cosmetic rough edge, not fixed, because it does not meet the bar
|
||||
of a proven implementation defect and touching `surface-bus.js` is outside
|
||||
this pass's narrow scope.
|
||||
|
||||
See Section J for exactly what the Node-level `vm`-context tests prove on
|
||||
their own, and Section R for the residual limitation this run does still
|
||||
leave open (real separate top-level windows vs. iframes).
|
||||
|
||||
## L. SciFi-XZBT Observation integration
|
||||
|
||||
Not applicable to Phase 6.2 — that is Phase 6.7/6.8.
|
||||
|
||||
## M. SciFi-XZBT interoperability results
|
||||
|
||||
Not applicable to Phase 6.2.
|
||||
|
||||
## N. Automated tests
|
||||
|
||||
See Section J. `node --test 'tests/*.test.js'`: 18/18 passing, including the
|
||||
2 pre-existing Haunted House tests, confirmed unaffected by the shared
|
||||
`contract-core.js` changes (see Section P).
|
||||
|
||||
## O. Browser/runtime verification
|
||||
|
||||
See Section K. 13/13 required proof points passing in a real browser over a
|
||||
real HTTP origin, 6/6 consecutive clean runs.
|
||||
|
||||
## P. Genericity verification
|
||||
|
||||
- `test-fixtures/reference-exhibits/shared/contract-core.js` was extended
|
||||
additively: every new field (`options.surfaces`, `options.contractMinor`,
|
||||
`options.xzbtVersion`) defaults to the exact pre-5.3 behavior when
|
||||
omitted. `describe()` omits the `surfaces` key entirely unless a
|
||||
`SurfaceCatalog` was supplied — verified directly by the "Non-surface-aware
|
||||
exhibits are unaffected" test.
|
||||
- Aquarium, Planetarium, and Haunted House were **not modified**. Haunted
|
||||
House's existing test suite (`tests/haunted-house.test.js`) was rerun
|
||||
unchanged against the modified shared core and still passes 2/2.
|
||||
- Nothing under `src/` (the NGN host itself) was touched in Phase 6.2.
|
||||
- Museum Gallery's own files contain no SciFi-XZBT vocabulary, and nothing
|
||||
in `contract-core.js`'s new `SurfaceCatalog` logic references any
|
||||
exhibit's domain content — it validates only the generic Contract 5.3
|
||||
§31.2/§31.3 shape (id grammar, required fields, the primary invariant, URL
|
||||
relative-safety).
|
||||
|
||||
## Q. Files changed
|
||||
|
||||
New:
|
||||
|
||||
```
|
||||
test-fixtures/reference-exhibits/museum-gallery/exhibit.js
|
||||
test-fixtures/reference-exhibits/museum-gallery/contract-adapter.js
|
||||
test-fixtures/reference-exhibits/museum-gallery/surface-bus.js
|
||||
test-fixtures/reference-exhibits/museum-gallery/control.html
|
||||
test-fixtures/reference-exhibits/museum-gallery/control.boot.js
|
||||
test-fixtures/reference-exhibits/museum-gallery/artifact.html
|
||||
test-fixtures/reference-exhibits/museum-gallery/artifact.boot.js
|
||||
test-fixtures/reference-exhibits/museum-gallery/info-wall.html
|
||||
test-fixtures/reference-exhibits/museum-gallery/info-wall.boot.js
|
||||
test-fixtures/reference-exhibits/museum-gallery/index.html
|
||||
test-fixtures/reference-exhibits/museum-gallery/style.css
|
||||
test-fixtures/reference-exhibits/museum-gallery/README.md
|
||||
test-fixtures/reference-exhibits/museum-gallery/verification.html
|
||||
tests/museum-gallery.test.js
|
||||
docs/reference/Museum-Gallery-Step6.2-Verification.md (this file)
|
||||
```
|
||||
|
||||
Modified (additive only — see Section P):
|
||||
|
||||
```
|
||||
test-fixtures/reference-exhibits/shared/contract-core.js
|
||||
```
|
||||
|
||||
Not modified: `src/`, `public/`, `server/`, Aquarium, Planetarium, Haunted
|
||||
House, Contract 5.2, Contract 5.3, the Step 6.1 architecture document.
|
||||
|
||||
## R. Known limitations
|
||||
|
||||
1. Live browser verification (`verification.html`) has been executed and
|
||||
passed 13/13 (Section K), but only as three iframes under one top-level
|
||||
page, not as three genuinely separate browser windows/tabs opened
|
||||
independently on the user's machine via `npm start` — see item 4 below
|
||||
for what that gap does and doesn't matter for.
|
||||
2. NGN attachment interoperability is not exercised: Museum Gallery declares
|
||||
`xzbt: '5.3'`; today's pre-6.3 NGN host always sends `xzbt: '5.2'` on
|
||||
every envelope, so an actual attach attempt from the current NGN would
|
||||
currently be rejected at the envelope-version check in
|
||||
`ContractCore.handleRequest`, never reaching contract-major negotiation.
|
||||
This is expected — NGN's own Contract 5.3 awareness is Phase 6.3+ work —
|
||||
but is recorded here explicitly rather than left implicit.
|
||||
3. The `role` field on surface descriptors (`control` / `ambient` /
|
||||
`information`) is illustrative only, per Contract 5.3 §31.2; no host
|
||||
behavior depends on its value yet.
|
||||
4. `verification.html`'s iframe-based harness proves same-origin,
|
||||
same-tab-group multi-document behavior (now executed, Section K); it
|
||||
does not by itself prove behavior across genuinely separate top-level
|
||||
browser windows (e.g. window-close/`beforeunload` timing can differ
|
||||
slightly between an iframe removal and a real window close — Section K
|
||||
already surfaced one such teardown-time quirk, observed as benign). A
|
||||
follow-up pass opening the three surfaces as real separate windows on
|
||||
the user's own machine via `npm start` is recommended before Phase
|
||||
6.6's broader reference-exhibit closure, though Phase 6.2 itself only
|
||||
requires proving the architecture, which the iframe harness and the
|
||||
Node-level tests both now do.
|
||||
|
||||
## S. Deferred Step 7 concerns
|
||||
|
||||
Casting, remote display endpoints, Chromecast/Google TV, network display
|
||||
discovery — untouched, as required. Nothing in Museum Gallery's surface
|
||||
descriptors or attachment transport names a display endpoint of any kind.
|
||||
|
||||
## T. Architecture review findings
|
||||
|
||||
- No hidden duplicate state machine: confirmed structurally (Section F) —
|
||||
non-primary documents cannot construct a Core even accidentally, since the
|
||||
constructing function is undefined in their scope.
|
||||
- No host/exhibit ownership confusion: NGN was not touched; every line of
|
||||
new code lives in the exhibit or its tests.
|
||||
- No implicit hard-coded surfaces: `surfaces` are discovered from
|
||||
`core.describe()`'s live `SurfaceCatalog` output in every test; nothing
|
||||
hard-codes surface names outside the exhibit's own descriptor list.
|
||||
- No transport coupling: Contract 5.3 was not touched by this phase, and the
|
||||
BroadcastChannel choice lives entirely in `surface-bus.js` and this
|
||||
document/README, never in the contract.
|
||||
- No display-endpoint assumptions: confirmed (Section S).
|
||||
- No revision inconsistencies: a dedicated test asserts `stateRevision`
|
||||
increments exactly once per transaction regardless of originating surface,
|
||||
and `registryRevision` is read directly from `core.registryRevision` with
|
||||
no separate surface-registry counter anywhere in the implementation.
|
||||
- Reconnect/stale-window behavior: not applicable in Phase 6.2 (no NGN
|
||||
session, no window lifecycle beyond the exhibit's own attach/detach) —
|
||||
deferred to Phase 6.5/6.6 as the Step 6.1 document already anticipated.
|
||||
|
||||
## U. Final verdict
|
||||
|
||||
**STEP 6.2 COMPLETE — REFERENCE MULTI-SURFACE MODEL PROVEN**
|
||||
|
||||
The multi-surface architecture is implemented and proven by two
|
||||
independent lines of evidence: 16 passing Node-level tests exercising a
|
||||
real cross-context `BroadcastChannel` attachment sequence (Section J), and
|
||||
a real-browser run of `verification.html` — three separate same-origin
|
||||
documents served over a genuine HTTP origin and driven with headless
|
||||
Chromium — passing all 13 required proof points (16/16 including harness
|
||||
self-checks) across 6 consecutive runs with no flakes and no code changes
|
||||
to the exhibit or its attachment transport (Section K). 2 pre-existing
|
||||
tests confirm no regression to the other reference exhibits. The residual
|
||||
item (Section R.4/R.1) — real separate top-level windows on the user's own
|
||||
machine via `npm start`, rather than same-page iframes — is a
|
||||
lower-confidence gap the brief's 13 proof points do not require closed for
|
||||
Phase 6.2, and is recommended before Phase 6.6 rather than blocking this
|
||||
verdict.
|
||||
|
||||
Per the Step 6 roadmap: **NGN Phase 6.3 (generic surface discovery in the
|
||||
host) has not begun.** No file under `src/` was touched.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Museum Gallery — Step 6.2 multi-surface reference exhibit
|
||||
|
||||
A deliberately small XZBT Contract 5.3 exhibit built to prove the
|
||||
multi-surface presentation model from
|
||||
`docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md` (Revision 2) and
|
||||
`docs/contract/XZBT-Exhibit-Contract-Specification-v5.3.md` Section 31 — not
|
||||
to be visually elaborate. See
|
||||
`docs/reference/Museum-Gallery-Step6.2-Verification.md` for the full
|
||||
verification writeup; this file documents the exhibit's own structure and
|
||||
its chosen internal attachment transport.
|
||||
|
||||
## Entry point
|
||||
|
||||
`control.html` is the primary surface (`surface.control`, `primary: true`)
|
||||
and the exhibit's canonical entry point — open it directly for full
|
||||
standalone operation, exactly like any other reference exhibit's
|
||||
`index.html`. `index.html` in this directory is a plain redirect to
|
||||
`control.html`, kept only for directory-listing consistency; it has no
|
||||
behavior of its own.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| id | label | primary | url | role |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `surface.control` | Control Room | `true` | `control.html` | control |
|
||||
| `surface.artifact` | Artifact Display | `false` | `artifact.html` | ambient |
|
||||
| `surface.info-wall` | Information Wall | `false` | `info-wall.html` | information |
|
||||
|
||||
## Exactly one Exhibit State Core
|
||||
|
||||
`control.html` is the only document that loads `contract-adapter.js` (and
|
||||
therefore the only document that constructs an `XZBTContractCore.ContractCore`
|
||||
instance). `artifact.html` and `info-wall.html` load only `surface-bus.js` —
|
||||
they are structurally incapable of constructing a second Core; the shared
|
||||
`tests/museum-gallery.test.js` suite asserts this directly (see "Exactly one
|
||||
Exhibit State Core exists" in that file).
|
||||
|
||||
## Chosen internal attachment transport: `BroadcastChannel`
|
||||
|
||||
`surface-bus.js` implements the generic attachment sequence from Step 6.1
|
||||
§7.2 using a same-origin `BroadcastChannel` named `xzbt-museum-gallery-core-v1`,
|
||||
with `control.html` as the single, clearly identified authoritative owner
|
||||
document (Contract 5.3 §31.7's "cross-document attachment" shape).
|
||||
|
||||
Why `BroadcastChannel` over the other two permitted shapes:
|
||||
|
||||
- it needs no window-handle bookkeeping (unlike `postMessage` to a specific
|
||||
`window` reference, which breaks if the owner window reference is lost or
|
||||
the surface was opened independently rather than via `window.open`);
|
||||
- it needs no separate worker lifecycle (unlike `SharedWorker`, which is
|
||||
unsupported in some embedding contexts and adds a process to reason
|
||||
about for a reference fixture this small);
|
||||
- every participant — owner or surface — only ever needs to know one
|
||||
string (the channel name), which keeps `control.html`, `artifact.html`,
|
||||
and `info-wall.html` fully decoupled from each other; none of them
|
||||
reference the other documents by name or handle.
|
||||
|
||||
This transport is exhibit-internal. It carries no XZBT contract envelope, is
|
||||
never observed by a host, and is not mentioned anywhere in Contract 5.3 —
|
||||
per Step 6.1 §7.2 and Contract 5.3 §31.7/§31.9, the contract only needs to
|
||||
know that surfaces exist and how a host opens one. A different exhibit is
|
||||
free to choose `SharedWorker`, in-process attachment, or another same-origin
|
||||
mechanism entirely.
|
||||
|
||||
### Message shapes (informal, exhibit-internal only)
|
||||
|
||||
- surface → owner: `{ type: 'attach', requestId }`
|
||||
- owner → surface: `{ type: 'attach.snapshot', inReplyTo, snapshot, registryRevision }`
|
||||
- owner → all: `{ type: 'core-event', event }` — one relayed copy of every
|
||||
normalized event the Core already emits (`state.changed`,
|
||||
`selection.changed`, `action.executed`, …)
|
||||
- surface → owner: `{ type: 'mutate', kind: 'set'|'invoke', target, value|args }`
|
||||
— routed straight into `core.applyMutation` / `core.invokeAction` with
|
||||
`source: 'ui'`, the same chokepoint the primary UI's own controls use
|
||||
- surface → owner: `{ type: 'detach' }` — bookkeeping only; never mutates
|
||||
state
|
||||
|
||||
## Shared state
|
||||
|
||||
`artifact.selected` (selection), `lighting.level` (range 0–1),
|
||||
`rotation.speed` (range 0–2), `labels.enabled` (state/boolean), plus one
|
||||
impulse, `action.spotlight-flash`, to prove `action.executed` propagation
|
||||
across surfaces.
|
||||
|
||||
## Native interactions proving the canonical mutation path
|
||||
|
||||
- Artifact Display's "Cycle artifact" button calls `link.mutate('set',
|
||||
'artifact.selected', …)`.
|
||||
- Information Wall's "Toggle labels" button calls `link.mutate('set',
|
||||
'labels.enabled', …)`.
|
||||
|
||||
Both are relayed by the bus into `core.applyMutation` on the one Core that
|
||||
`control.html` owns — the same call the Control Room's own controls and a
|
||||
contract `set` from NGN would make. See the verification document for the
|
||||
event-sequence and stateRevision proof.
|
||||
|
||||
## Running it directly (no test harness)
|
||||
|
||||
Serve this repository with `npm start` and open:
|
||||
|
||||
```
|
||||
http://127.0.0.1:4173/test-fixtures/reference-exhibits/museum-gallery/control.html
|
||||
```
|
||||
|
||||
Then open `artifact.html` and `info-wall.html` in separate tabs/windows from
|
||||
the same origin. Changing state in any one window updates the other two.
|
||||
Closing and reopening a non-primary surface reflects current state
|
||||
immediately. Opening `artifact.html` or `info-wall.html` alone, with
|
||||
`control.html` not open anywhere, shows a "Waiting for the Control Room
|
||||
surface to be open…" state rather than inventing its own state.
|
||||
|
||||
## Known limitation: NGN attachment is not exercised in Step 6.2
|
||||
|
||||
`control.html` includes the optional `XZBTHostTransport`, matching every
|
||||
other reference exhibit. Because Museum Gallery declares `xzbtVersion:
|
||||
'5.3'` while the current (pre-6.3) NGN host always sends the envelope tag
|
||||
`xzbt: '5.2'`, an actual attach attempt from today's NGN would be rejected
|
||||
at the envelope-version check rather than negotiating a Contract-major-5
|
||||
session. This is expected and intentional: NGN's generic surface discovery
|
||||
and version negotiation are Phase 6.3+ work, explicitly out of scope for
|
||||
Step 6.2. See the verification document's known-limitations section.
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Museum Gallery — Artifact Display boot (non-primary surface).
|
||||
*
|
||||
* This document deliberately never loads contract-core.js, exhibit.js, or
|
||||
* contract-adapter.js. It has no way to construct an Exhibit State Core --
|
||||
* it can only attach to the one the Control Room document created, over
|
||||
* surface-bus.js. If the Control Room is not open, this degrades to a clear
|
||||
* waiting state rather than inventing its own state (Contract 5.3 Section
|
||||
* 31.8, Step 6.1 Section 7.2).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
var el = Shell.el;
|
||||
var statusEl = el('status');
|
||||
var announcer = new Shell.Announcer({ node: el('announcement'), idleText: 'Waiting for shared state.' });
|
||||
var cycleButton = el('cycle-button');
|
||||
|
||||
var ARTIFACT_ORDER = ['the-orrery', 'star-map', 'meteorite'];
|
||||
var ARTIFACT_LABELS = { 'the-orrery': 'The Orrery', 'star-map': 'Star Map', meteorite: 'Meteorite Fragment' };
|
||||
|
||||
/* This is a read-only MIRROR of authoritative state, not a second copy of
|
||||
* exhibit state: it is discarded and re-seeded from a fresh snapshot on
|
||||
* every attach, and every write attempt is a mutate() call routed back
|
||||
* through the owner's canonical mutation path -- this document never
|
||||
* writes to `latest` in response to a user action, only in response to
|
||||
* a snapshot or an event relayed from the one Core. */
|
||||
var latest = null;
|
||||
|
||||
function render() {
|
||||
if (!latest) return;
|
||||
Shell.setText(el('artifact-name'), ARTIFACT_LABELS[latest['artifact.selected']] || latest['artifact.selected']);
|
||||
Shell.setText(el('lighting-readout'), Shell.formatPercent(latest['lighting.level']));
|
||||
Shell.setText(el('rotation-readout'), latest['rotation.speed'].toFixed(1) + 'x');
|
||||
}
|
||||
|
||||
var link = window.MuseumGallerySurfaceBus.attach({
|
||||
timeoutMs: 1500,
|
||||
onSnapshot: function (snapshot) {
|
||||
latest = snapshot.values;
|
||||
Shell.setText(statusEl, 'Attached to Control Room.');
|
||||
statusEl.classList.remove('is-waiting');
|
||||
cycleButton.disabled = false;
|
||||
render();
|
||||
},
|
||||
onEvent: function (event) {
|
||||
if (!latest) return;
|
||||
if (event.type === 'state.changed' || event.type === 'selection.changed') {
|
||||
latest[event.target] = event.value;
|
||||
render();
|
||||
} else if (event.type === 'action.executed' && event.target === 'action.spotlight-flash') {
|
||||
announcer.flash('Spotlight flashed.');
|
||||
}
|
||||
},
|
||||
onTimeout: function () {
|
||||
Shell.setText(statusEl, 'Waiting for the Control Room surface to be open…');
|
||||
statusEl.classList.add('is-waiting');
|
||||
cycleButton.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
cycleButton.addEventListener('click', function () {
|
||||
if (!latest) return;
|
||||
var idx = ARTIFACT_ORDER.indexOf(latest['artifact.selected']);
|
||||
var next = ARTIFACT_ORDER[(idx + 1) % ARTIFACT_ORDER.length];
|
||||
/* Routed to the owner's canonical mutation path -- this is the required
|
||||
* proof that a native interaction on a non-primary surface obeys the
|
||||
* same mutation/revision/source/event semantics as primary UI or a
|
||||
* contract set/invoke (Contract 5.3 Section 31.7). */
|
||||
link.mutate('set', 'artifact.selected', next);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function () { link.detach(); });
|
||||
window.__museumGalleryDebug = { link: link };
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Museum Gallery — Artifact Display</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit" style="grid-template-columns: minmax(0,1fr);">
|
||||
<header class="exhibit-header">
|
||||
<h1>Museum Gallery — Artifact Display</h1>
|
||||
<span class="subtitle">Non-primary surface · attaches to the Control Room</span>
|
||||
<span class="status" id="contract-status">surface</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<div class="gallery-display">
|
||||
<p class="artifact-name" id="artifact-name">—</p>
|
||||
<p class="readout-row">Lighting: <span id="lighting-readout">--</span></p>
|
||||
<p class="readout-row">Rotation: <span id="rotation-readout">--</span></p>
|
||||
<p class="button-row" style="margin-top:16px; justify-content:center;">
|
||||
<button id="cycle-button" type="button" disabled>Cycle artifact</button>
|
||||
</p>
|
||||
<p class="surface-status" id="status">Attaching…</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Artifact Display</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Waiting for shared state.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="surface-bus.js"></script>
|
||||
<script src="artifact.boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Museum Gallery — XZBT Exhibit Contract 5.3 adapter.
|
||||
*
|
||||
* Same three responsibilities as every other reference exhibit's adapter
|
||||
* (declare the catalog, bind it to the real exhibit, hold no state of its
|
||||
* own) plus one addition that only this exhibit needs: declaring the
|
||||
* Contract 5.3 `surfaces` catalog (Section 31).
|
||||
*
|
||||
* This file constructs exactly ONE Exhibit State Core. It is loaded only by
|
||||
* control.html — the primary surface — which is the sole owner of the Core
|
||||
* this exhibit instance uses. artifact.html and info-wall.html never load
|
||||
* this file; they attach to the Core that control.html created, over the
|
||||
* exhibit-internal bus in surface-bus.js. See README.md for the full
|
||||
* attachment-architecture writeup.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Core = window.XZBTContractCore;
|
||||
var Gallery = window.MuseumGalleryExhibit.Gallery;
|
||||
var ARTIFACTS = window.MuseumGalleryExhibit.ARTIFACTS;
|
||||
var ARTIFACT_LABELS = window.MuseumGalleryExhibit.ARTIFACT_LABELS;
|
||||
|
||||
var IDENTITY = {
|
||||
product: 'Museum Gallery',
|
||||
version: '0.1.0',
|
||||
build: 'reference-exhibit'
|
||||
};
|
||||
|
||||
var DESCRIPTORS = [
|
||||
{
|
||||
id: 'artifact.selected',
|
||||
kind: 'selection',
|
||||
label: 'Selected Artifact',
|
||||
description: 'The artifact currently on display.',
|
||||
options: ARTIFACTS.map(function (id) {
|
||||
return { value: id, label: ARTIFACT_LABELS[id] };
|
||||
}),
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'gallery', requires: []
|
||||
},
|
||||
{
|
||||
id: 'lighting.level',
|
||||
kind: 'range',
|
||||
label: 'Lighting Level',
|
||||
description: 'Gallery lighting, 0 (dark) to 1 (full).',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'gallery', requires: []
|
||||
},
|
||||
{
|
||||
id: 'rotation.speed',
|
||||
kind: 'range',
|
||||
label: 'Rotation Speed',
|
||||
description: 'Turntable speed under the displayed artifact.',
|
||||
min: 0, max: 2, step: 0.1,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'gallery', requires: []
|
||||
},
|
||||
{
|
||||
id: 'labels.enabled',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Labels Enabled',
|
||||
description: 'Whether descriptive labels are shown on the wall and display.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'gallery', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.spotlight-flash',
|
||||
kind: 'impulse',
|
||||
label: 'Spotlight Flash',
|
||||
description: 'Briefly flash a spotlight on the current artifact.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: [], arguments: []
|
||||
}
|
||||
];
|
||||
|
||||
/* Contract 5.3 §31.2 surface descriptors. surface.control is primary. */
|
||||
var SURFACES = [
|
||||
{
|
||||
id: 'surface.control', label: 'Control Room', kind: 'surface',
|
||||
primary: true, url: 'control.html', role: 'control'
|
||||
},
|
||||
{
|
||||
id: 'surface.artifact', label: 'Artifact Display', kind: 'surface',
|
||||
primary: false, url: 'artifact.html', role: 'ambient'
|
||||
},
|
||||
{
|
||||
id: 'surface.info-wall', label: 'Information Wall', kind: 'surface',
|
||||
primary: false, url: 'info-wall.html', role: 'information'
|
||||
}
|
||||
];
|
||||
|
||||
function createMuseumGalleryContract(gallery) {
|
||||
var catalog = new Core.Catalog(DESCRIPTORS);
|
||||
|
||||
var capabilities = new Core.CapabilityRegistry();
|
||||
capabilities.declare('render', 'ready');
|
||||
|
||||
var surfaces = new Core.SurfaceCatalog(SURFACES);
|
||||
|
||||
var setters = {
|
||||
'artifact.selected': function (v) { return gallery.setArtifact(v); },
|
||||
'lighting.level': function (v) { return gallery.setLightingLevel(v); },
|
||||
'rotation.speed': function (v) { return gallery.setRotationSpeed(v); },
|
||||
'labels.enabled': function (v) { return gallery.setLabelsEnabled(v); }
|
||||
};
|
||||
|
||||
var readers = {};
|
||||
var ids = catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
(function (id) {
|
||||
readers[id] = function () { return gallery.read(id); };
|
||||
})(ids[i]);
|
||||
}
|
||||
|
||||
var actions = {
|
||||
'action.spotlight-flash': function () { return gallery.flashSpotlight(); }
|
||||
};
|
||||
|
||||
var core = new Core.ContractCore({
|
||||
identity: IDENTITY,
|
||||
catalog: catalog,
|
||||
capabilities: capabilities,
|
||||
setters: setters,
|
||||
readers: readers,
|
||||
actions: actions,
|
||||
surfaces: surfaces,
|
||||
/* Contract 5.3 adoption is per-instance; every other reference exhibit
|
||||
* keeps the shared core's Contract 5.2 defaults untouched. */
|
||||
contractMinor: 3,
|
||||
xzbtVersion: '5.3'
|
||||
});
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
window.MuseumGalleryContract = {
|
||||
create: createMuseumGalleryContract,
|
||||
IDENTITY: IDENTITY,
|
||||
DESCRIPTORS: DESCRIPTORS,
|
||||
SURFACES: SURFACES
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Museum Gallery — Control Room boot.
|
||||
*
|
||||
* This is the ONLY document in this exhibit that constructs an Exhibit
|
||||
* State Core. Every other surface (artifact.html, info-wall.html) attaches
|
||||
* to the Core this document creates, over the exhibit-internal bus in
|
||||
* surface-bus.js -- see README.md.
|
||||
*
|
||||
* This document remains fully standalone: nothing below requires any other
|
||||
* surface to be open, and nothing below requires XZBT-NGN. The optional
|
||||
* HostTransport at the bottom lets an NGN attach later, exactly like every
|
||||
* other reference exhibit -- it changes nothing about ordinary operation
|
||||
* when no host ever connects.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
var el = Shell.el;
|
||||
|
||||
var gallery = new window.MuseumGalleryExhibit.Gallery();
|
||||
var core = window.MuseumGalleryContract.create(gallery);
|
||||
|
||||
var announcer = new Shell.Announcer({ node: el('announcement'), idleText: 'Control Room standing by.' });
|
||||
|
||||
var artifactSelect = el('artifact-select');
|
||||
var artifactDescriptor = core.catalog.get('artifact.selected');
|
||||
artifactDescriptor.options.forEach(function (opt) {
|
||||
var option = document.createElement('option');
|
||||
option.value = opt.value;
|
||||
option.textContent = opt.label;
|
||||
artifactSelect.appendChild(option);
|
||||
});
|
||||
|
||||
var lightingRange = el('lighting-range');
|
||||
var rotationRange = el('rotation-range');
|
||||
var labelsButton = el('labels-button');
|
||||
var subscriberCountEl = el('subscriber-count');
|
||||
|
||||
function sync() {
|
||||
var values = core.stateSnapshot().values;
|
||||
Shell.setText(el('artifact-name'), window.MuseumGalleryExhibit.ARTIFACT_LABELS[values['artifact.selected']]);
|
||||
artifactSelect.value = values['artifact.selected'];
|
||||
|
||||
Shell.setText(el('lighting-readout'), Shell.formatPercent(values['lighting.level']));
|
||||
Shell.setText(el('lighting-panel-readout'), Shell.formatPercent(values['lighting.level']));
|
||||
lightingRange.value = values['lighting.level'];
|
||||
|
||||
Shell.setText(el('rotation-readout'), values['rotation.speed'].toFixed(1) + 'x');
|
||||
Shell.setText(el('rotation-panel-readout'), values['rotation.speed'].toFixed(1) + 'x');
|
||||
rotationRange.value = values['rotation.speed'];
|
||||
|
||||
Shell.setText(el('labels-readout'), values['labels.enabled'] ? 'ON' : 'OFF');
|
||||
labelsButton.setAttribute('aria-pressed', values['labels.enabled'] ? 'true' : 'false');
|
||||
}
|
||||
sync();
|
||||
|
||||
/* Every native control here calls the SAME core.applyMutation/invokeAction
|
||||
* chokepoint a contract set/invoke (from NGN) or a bus-relayed mutation
|
||||
* (from a non-primary surface) would call. There is exactly one path. */
|
||||
artifactSelect.addEventListener('change', function () {
|
||||
core.applyMutation('artifact.selected', artifactSelect.value, 'ui');
|
||||
});
|
||||
lightingRange.addEventListener('input', function () {
|
||||
core.applyMutation('lighting.level', parseFloat(lightingRange.value), 'ui');
|
||||
});
|
||||
rotationRange.addEventListener('input', function () {
|
||||
core.applyMutation('rotation.speed', parseFloat(rotationRange.value), 'ui');
|
||||
});
|
||||
labelsButton.addEventListener('click', function () {
|
||||
var current = core.stateSnapshot().values['labels.enabled'];
|
||||
core.applyMutation('labels.enabled', !current, 'ui');
|
||||
});
|
||||
el('flash-button').addEventListener('click', function () {
|
||||
core.invokeAction('action.spotlight-flash', {}, 'ui');
|
||||
announcer.flash('Spotlight flashed.');
|
||||
});
|
||||
|
||||
/* The bus owner forwards every core event (however it originated -- this
|
||||
* document's own controls, or a mutate request relayed from another
|
||||
* surface) to attached surfaces, and answers their attach requests with a
|
||||
* fresh snapshot read straight from this Core -- never a second
|
||||
* computation. */
|
||||
var bus = window.MuseumGallerySurfaceBus.createOwner(core);
|
||||
var priorOnEvent = core.onEvent;
|
||||
core.onEvent = function (event) {
|
||||
if (typeof priorOnEvent === 'function') priorOnEvent(event);
|
||||
sync();
|
||||
Shell.setText(subscriberCountEl, String(bus.attachedCount()));
|
||||
};
|
||||
|
||||
/* Optional NGN attachment -- same optional transport every reference
|
||||
* exhibit includes. Museum Gallery Phase 6.2 does not exercise this path;
|
||||
* see the verification document's known-limitations section. */
|
||||
var transport = new window.XZBTHostTransport({
|
||||
core: core,
|
||||
onMessage: function () { el('contract-status').textContent = 'host attached'; }
|
||||
});
|
||||
|
||||
window.__museumGalleryDebug = { core: core, gallery: gallery, bus: bus, transport: transport, sync: sync };
|
||||
})();
|
||||
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Museum Gallery — Control Room (primary surface)</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit">
|
||||
<header class="exhibit-header">
|
||||
<h1>Museum Gallery — Control Room</h1>
|
||||
<span class="subtitle">Primary surface · Contract 5.3 · standalone-capable</span>
|
||||
<span class="status" id="contract-status">standalone</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<div class="gallery-display">
|
||||
<p class="artifact-name" id="artifact-name">—</p>
|
||||
<p class="readout-row">Lighting: <span id="lighting-readout"></span></p>
|
||||
<p class="readout-row">Rotation: <span id="rotation-readout"></span></p>
|
||||
<p class="readout-row">Labels: <span id="labels-readout"></span></p>
|
||||
<p class="surface-status" id="surface-status">Attached non-primary surfaces: <span id="subscriber-count">0</span></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="panel">
|
||||
<section>
|
||||
<h2>Artifact</h2>
|
||||
<div class="control">
|
||||
<label for="artifact-select">Selected artifact</label>
|
||||
<select id="artifact-select"></select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Environment</h2>
|
||||
<div class="control">
|
||||
<label for="lighting-range">
|
||||
<span>Lighting level</span>
|
||||
<span class="readout" id="lighting-panel-readout"></span>
|
||||
</label>
|
||||
<input type="range" id="lighting-range" min="0" max="1" step="0.01">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="rotation-range">
|
||||
<span>Rotation speed</span>
|
||||
<span class="readout" id="rotation-panel-readout"></span>
|
||||
</label>
|
||||
<input type="range" id="rotation-range" min="0" max="2" step="0.1">
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Labels enabled</span>
|
||||
<button id="labels-button" type="button" aria-pressed="false">Toggle</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="button-row">
|
||||
<button id="flash-button" type="button">Flash spotlight</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Control Room</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Control Room standing by.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="../shared/contract-core.js"></script>
|
||||
<script src="../shared/host-transport.js"></script>
|
||||
<script src="exhibit.js"></script>
|
||||
<script src="contract-adapter.js"></script>
|
||||
<script src="surface-bus.js"></script>
|
||||
<script src="control.boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Museum Gallery — domain model.
|
||||
*
|
||||
* Deliberately tiny (Step 6.2 brief: "prove the architecture, not to be
|
||||
* visually elaborate"). No DOM, no contract awareness, no transport — a
|
||||
* plain state object with absolute, idempotent setters, exactly the same
|
||||
* shape as Aquarium's and Haunted House's exhibit models.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ARTIFACTS = ['the-orrery', 'star-map', 'meteorite'];
|
||||
var ARTIFACT_LABELS = {
|
||||
'the-orrery': 'The Orrery',
|
||||
'star-map': 'Star Map',
|
||||
meteorite: 'Meteorite Fragment'
|
||||
};
|
||||
|
||||
function Gallery() {
|
||||
this.artifact = ARTIFACTS[0];
|
||||
this.lightingLevel = 0.6;
|
||||
this.rotationSpeed = 0.4;
|
||||
this.labelsEnabled = true;
|
||||
this.spotlightFlashCount = 0;
|
||||
}
|
||||
|
||||
Gallery.prototype.setArtifact = function (value) {
|
||||
if (ARTIFACTS.indexOf(value) === -1 || this.artifact === value) return { changed: false };
|
||||
this.artifact = value;
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
Gallery.prototype.setLightingLevel = function (value) {
|
||||
if (this.lightingLevel === value) return { changed: false };
|
||||
this.lightingLevel = value;
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
Gallery.prototype.setRotationSpeed = function (value) {
|
||||
if (this.rotationSpeed === value) return { changed: false };
|
||||
this.rotationSpeed = value;
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
Gallery.prototype.setLabelsEnabled = function (value) {
|
||||
if (this.labelsEnabled === value) return { changed: false };
|
||||
this.labelsEnabled = value;
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
Gallery.prototype.read = function (id) {
|
||||
switch (id) {
|
||||
case 'artifact.selected': return this.artifact;
|
||||
case 'lighting.level': return this.lightingLevel;
|
||||
case 'rotation.speed': return this.rotationSpeed;
|
||||
case 'labels.enabled': return this.labelsEnabled;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
Gallery.prototype.flashSpotlight = function () {
|
||||
this.spotlightFlashCount += 1;
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
window.MuseumGalleryExhibit = {
|
||||
Gallery: Gallery,
|
||||
ARTIFACTS: ARTIFACTS,
|
||||
ARTIFACT_LABELS: ARTIFACT_LABELS
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="0; url=control.html">
|
||||
<title>Museum Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
Museum Gallery is a multi-surface exhibit (Contract 5.3, Step 6.2). It has
|
||||
no single "index" document the way a single-surface exhibit does -- its
|
||||
canonical entry point is its primary surface, control.html (Contract 5.3
|
||||
Section 31.3/31.8). This file exists only so the fixture's directory keeps
|
||||
the same index.html convention other reference exhibits use; it performs
|
||||
no logic of its own and simply points at the real entry point.
|
||||
-->
|
||||
<p>Redirecting to <a href="control.html">the Control Room (primary surface)</a>…</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Museum Gallery — Information Wall boot (non-primary surface).
|
||||
*
|
||||
* Structurally identical in principle to artifact.boot.js: no Core here,
|
||||
* only an attachment to the Control Room's Core over surface-bus.js, and a
|
||||
* clear waiting state if that Core is not reachable.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
var el = Shell.el;
|
||||
var statusEl = el('status');
|
||||
var announcer = new Shell.Announcer({ node: el('announcement'), idleText: 'Waiting for shared state.' });
|
||||
var toggleButton = el('toggle-button');
|
||||
|
||||
var ARTIFACT_LABELS = { 'the-orrery': 'The Orrery', 'star-map': 'Star Map', meteorite: 'Meteorite Fragment' };
|
||||
|
||||
var latest = null; // read-only mirror; see artifact.boot.js for the rationale
|
||||
|
||||
function render() {
|
||||
if (!latest) return;
|
||||
Shell.setText(el('artifact-name'), ARTIFACT_LABELS[latest['artifact.selected']] || latest['artifact.selected']);
|
||||
Shell.setText(el('labels-readout'), latest['labels.enabled'] ? 'Labels: ON' : 'Labels: OFF');
|
||||
}
|
||||
|
||||
var link = window.MuseumGallerySurfaceBus.attach({
|
||||
timeoutMs: 1500,
|
||||
onSnapshot: function (snapshot) {
|
||||
latest = snapshot.values;
|
||||
Shell.setText(statusEl, 'Attached to Control Room.');
|
||||
statusEl.classList.remove('is-waiting');
|
||||
toggleButton.disabled = false;
|
||||
render();
|
||||
},
|
||||
onEvent: function (event) {
|
||||
if (!latest) return;
|
||||
if (event.type === 'state.changed' || event.type === 'selection.changed') {
|
||||
latest[event.target] = event.value;
|
||||
render();
|
||||
} else if (event.type === 'action.executed' && event.target === 'action.spotlight-flash') {
|
||||
announcer.flash('Spotlight flashed.');
|
||||
}
|
||||
},
|
||||
onTimeout: function () {
|
||||
Shell.setText(statusEl, 'Waiting for the Control Room surface to be open…');
|
||||
statusEl.classList.add('is-waiting');
|
||||
toggleButton.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
toggleButton.addEventListener('click', function () {
|
||||
if (!latest) return;
|
||||
/* Same canonical mutation path proof as the Artifact Display's cycle
|
||||
* button, exercising a boolean state target instead of a selection. */
|
||||
link.mutate('set', 'labels.enabled', !latest['labels.enabled']);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function () { link.detach(); });
|
||||
window.__museumGalleryDebug = { link: link };
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Museum Gallery — Information Wall</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit" style="grid-template-columns: minmax(0,1fr);">
|
||||
<header class="exhibit-header">
|
||||
<h1>Museum Gallery — Information Wall</h1>
|
||||
<span class="subtitle">Non-primary surface · attaches to the Control Room</span>
|
||||
<span class="status" id="contract-status">surface</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<div class="gallery-display">
|
||||
<p class="artifact-name" id="artifact-name">—</p>
|
||||
<p class="readout-row" id="labels-readout">--</p>
|
||||
<p class="button-row" style="margin-top:16px; justify-content:center;">
|
||||
<button id="toggle-button" type="button" disabled>Toggle labels</button>
|
||||
</p>
|
||||
<p class="surface-status" id="status">Attaching…</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Information Wall</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Waiting for shared state.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="surface-bus.js"></script>
|
||||
<script src="info-wall.boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
/* Museum Gallery — domain styling only. Layout and controls come from the shell. */
|
||||
|
||||
body {
|
||||
background: #14100a;
|
||||
color: #f2e9da;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
background: linear-gradient(90deg, rgba(150, 110, 40, 0.3), rgba(255, 255, 255, 0.03));
|
||||
border-color: rgba(220, 180, 110, 0.25);
|
||||
}
|
||||
|
||||
.exhibit-header h1 { color: #f0d9a8; }
|
||||
|
||||
.stage {
|
||||
border-color: rgba(220, 180, 110, 0.25);
|
||||
box-shadow: inset 0 0 60px rgba(50, 35, 10, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.gallery-display {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.gallery-display .artifact-name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: #f0d9a8;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.gallery-display .readout-row {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.gallery-display .surface-status {
|
||||
margin-top: 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.gallery-display .surface-status.is-waiting {
|
||||
color: #ffcf7a;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel { background: rgba(150, 110, 40, 0.14); border-color: rgba(220, 180, 110, 0.2); }
|
||||
.panel h2 { color: #e0bf7d; }
|
||||
|
||||
input[type="range"] { accent-color: #d9a441; }
|
||||
|
||||
button {
|
||||
background: rgba(217, 164, 65, 0.12);
|
||||
border-color: rgba(220, 180, 110, 0.35);
|
||||
color: #f2e9da;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(217, 164, 65, 0.24); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
select {
|
||||
background: rgba(30, 20, 8, 0.85);
|
||||
border-color: rgba(220, 180, 110, 0.3);
|
||||
}
|
||||
|
||||
.exhibit-footer { background: rgba(150, 110, 40, 0.14); border-color: rgba(220, 180, 110, 0.2); }
|
||||
.exhibit-footer .announcement-label { color: #e0bf7d; opacity: 0.7; }
|
||||
.exhibit-header .status { color: #e0bf7d; }
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Museum Gallery — exhibit-internal surface-attachment bus.
|
||||
*
|
||||
* This implements the generic attachment sequence from the Step 6.1
|
||||
* architecture document (docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md
|
||||
* §7.2) and Contract 5.3 §31.7's "cross-document attachment" transport
|
||||
* shape: BroadcastChannel, same-origin, with a clearly identified
|
||||
* authoritative owner document (the primary surface, control.html).
|
||||
*
|
||||
* This file is NOT part of the XZBT Exhibit Contract wire protocol. It
|
||||
* carries no contract envelope, is never observed by a host, and its
|
||||
* messages are exhibit-internal only. An exhibit is free to choose a
|
||||
* different transport (SharedWorker, in-process) — this reference exhibit's
|
||||
* choice of BroadcastChannel is documented in README.md, not in Contract 5.3.
|
||||
*
|
||||
* The sequence (Step 6.1 §7.2, steps 1-5):
|
||||
* 1. surface requests attachment
|
||||
* 2. owner replies with current snapshot + stateRevision
|
||||
* 3. owner registers the surface as a subscriber for ongoing events
|
||||
* 4. native interactions on the surface are posted back to the owner,
|
||||
* which routes them through the Core's one canonical mutation path
|
||||
* 5. on detach, the owner simply stops delivering to that channel
|
||||
* instance; no state is held by the surface, so nothing reconciles.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CHANNEL_NAME = 'xzbt-museum-gallery-core-v1';
|
||||
|
||||
function randomId() {
|
||||
return 'req-' + Math.random().toString(16).slice(2) + Date.now().toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner side. Call once, in the document that constructs the Exhibit
|
||||
* State Core (control.html). Wraps `core.onEvent` to forward every
|
||||
* normalized event to attached surfaces, and answers attach/mutate
|
||||
* requests by calling straight into the Core's existing methods — never a
|
||||
* second computation of state.
|
||||
*
|
||||
* @param {object} core an XZBTContractCore.ContractCore instance
|
||||
*/
|
||||
function createOwner(core) {
|
||||
var channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
var attachedCount = 0;
|
||||
|
||||
channel.onmessage = function (ev) {
|
||||
var msg = ev.data;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
if (msg.type === 'attach') {
|
||||
attachedCount += 1;
|
||||
channel.postMessage({
|
||||
type: 'attach.snapshot',
|
||||
inReplyTo: msg.requestId,
|
||||
snapshot: core.stateSnapshot(),
|
||||
registryRevision: core.registryRevision
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'mutate') {
|
||||
/* Every surface-originated interaction that changes contract-visible
|
||||
* state or executes a contract-visible action goes through exactly
|
||||
* the same core.applyMutation/invokeAction chokepoint a contract
|
||||
* set/invoke from NGN would use (Contract 5.3 §31.7). Source is
|
||||
* always 'ui': a surface interaction is exhibit-native UI regardless
|
||||
* of which surface it originated on (Contract 5.3 §15). */
|
||||
if (msg.kind === 'set' && typeof msg.target === 'string') {
|
||||
core.applyMutation(msg.target, msg.value, 'ui');
|
||||
} else if (msg.kind === 'invoke' && typeof msg.target === 'string') {
|
||||
core.invokeAction(msg.target, msg.args || {}, 'ui');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'detach') {
|
||||
attachedCount = Math.max(0, attachedCount - 1);
|
||||
}
|
||||
};
|
||||
|
||||
var priorOnEvent = core.onEvent;
|
||||
core.onEvent = function (event) {
|
||||
if (typeof priorOnEvent === 'function') priorOnEvent(event);
|
||||
channel.postMessage({ type: 'core-event', event: event });
|
||||
};
|
||||
|
||||
return {
|
||||
channel: channel,
|
||||
attachedCount: function () { return attachedCount; },
|
||||
close: function () { channel.close(); }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface side. Call from any non-primary surface document. Requests
|
||||
* attachment and waits `timeoutMs` for a reply; if the owner document
|
||||
* (control.html) is not open, `onTimeout` fires and the surface must
|
||||
* degrade to a clear waiting/disconnected state rather than inventing its
|
||||
* own Core (Contract 5.3 §31.8, Step 6.1 §7.2).
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {number} [options.timeoutMs]
|
||||
* @param {function} [options.onSnapshot] (snapshot, registryRevision)
|
||||
* @param {function} [options.onEvent] (normalizedEvent)
|
||||
* @param {function} [options.onTimeout]
|
||||
*/
|
||||
function attach(options) {
|
||||
options = options || {};
|
||||
var channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
var requestId = randomId();
|
||||
var attached = false;
|
||||
|
||||
var timer = setTimeout(function () {
|
||||
if (attached) return;
|
||||
if (typeof options.onTimeout === 'function') options.onTimeout();
|
||||
}, options.timeoutMs || 1000);
|
||||
|
||||
channel.onmessage = function (ev) {
|
||||
var msg = ev.data;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
if (msg.type === 'attach.snapshot' && msg.inReplyTo === requestId && !attached) {
|
||||
attached = true;
|
||||
clearTimeout(timer);
|
||||
if (typeof options.onSnapshot === 'function') {
|
||||
options.onSnapshot(msg.snapshot, msg.registryRevision);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'core-event' && attached) {
|
||||
if (typeof options.onEvent === 'function') options.onEvent(msg.event);
|
||||
}
|
||||
};
|
||||
|
||||
channel.postMessage({ type: 'attach', requestId: requestId });
|
||||
|
||||
return {
|
||||
channel: channel,
|
||||
isAttached: function () { return attached; },
|
||||
/** Route a native interaction through the owner's canonical mutation path. */
|
||||
mutate: function (kind, target, valueOrArgs) {
|
||||
if (!attached) return false;
|
||||
var payload = { type: 'mutate', kind: kind, target: target };
|
||||
if (kind === 'set') payload.value = valueOrArgs;
|
||||
else payload.args = valueOrArgs || {};
|
||||
channel.postMessage(payload);
|
||||
return true;
|
||||
},
|
||||
detach: function () {
|
||||
if (attached) channel.postMessage({ type: 'detach' });
|
||||
clearTimeout(timer);
|
||||
channel.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.MuseumGallerySurfaceBus = {
|
||||
CHANNEL_NAME: CHANNEL_NAME,
|
||||
createOwner: createOwner,
|
||||
attach: attach
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,236 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Museum Gallery — Step 6.2 browser verification</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", Tahoma, sans-serif; background: #10131a; color: #e8ecf2; margin: 0; padding: 16px; }
|
||||
h1 { font-size: 16px; }
|
||||
.frames { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
iframe { width: 32%; height: 280px; border: 1px solid rgba(255,255,255,0.2); background: #fff; }
|
||||
#log { font: 12px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); padding: 10px; border-radius: 4px; max-height: 55vh; overflow: auto; }
|
||||
.pass { color: #7be08a; }
|
||||
.fail { color: #ff8080; font-weight: 600; }
|
||||
.diag { color: #f0c674; white-space: pre-wrap; margin: 2px 0 8px 18px; }
|
||||
#summary { margin: 10px 0; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Museum Gallery — Step 6.2 real-browser verification</h1>
|
||||
<p>
|
||||
Opens the three surfaces as three separate same-origin documents (iframes,
|
||||
one per surface — a genuine multi-document, multi-BroadcastChannel-endpoint
|
||||
test, not a simulation) and drives the required proof points listed in the
|
||||
Step 6.2 brief. Every wait below is condition-based (polls a real signal —
|
||||
attachment state, DOM content, or stateRevision — until it is true or a
|
||||
timeout elapses); nothing is timed by a fixed sleep standing in for an
|
||||
actual event. Click "Run checks" once all three frames have loaded.
|
||||
</p>
|
||||
<button id="run" type="button">Run checks</button>
|
||||
<div id="summary"></div>
|
||||
<div class="frames">
|
||||
<iframe id="frame-control" src="control.html" title="Control Room"></iframe>
|
||||
<iframe id="frame-artifact" src="artifact.html" title="Artifact Display"></iframe>
|
||||
<iframe id="frame-info" src="info-wall.html" title="Information Wall"></iframe>
|
||||
</div>
|
||||
<div id="log"></div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var logEl = document.getElementById('log');
|
||||
var summaryEl = document.getElementById('summary');
|
||||
var results = [];
|
||||
|
||||
function log(ok, name, detail) {
|
||||
results.push(ok);
|
||||
var line = document.createElement('div');
|
||||
line.className = ok ? 'pass' : 'fail';
|
||||
line.textContent = (ok ? 'PASS' : 'FAIL') + ' — ' + name + (detail ? ' (' + detail + ')' : '');
|
||||
logEl.appendChild(line);
|
||||
}
|
||||
|
||||
function diag(text) {
|
||||
var line = document.createElement('div');
|
||||
line.className = 'diag';
|
||||
line.textContent = text;
|
||||
logEl.appendChild(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `checkFn` (a synchronous predicate reading real page/Core state)
|
||||
* every `intervalMs` until it returns true or `timeoutMs` elapses.
|
||||
* Never used to paper over a race with a fixed sleep -- the condition
|
||||
* itself is the thing under test.
|
||||
*/
|
||||
function pollUntil(checkFn, timeoutMs, intervalMs) {
|
||||
var deadline = Date.now() + timeoutMs;
|
||||
return new Promise(function (resolve) {
|
||||
(function poll() {
|
||||
var ok;
|
||||
try { ok = !!checkFn(); } catch (e) { ok = false; }
|
||||
if (ok) return resolve(true);
|
||||
if (Date.now() > deadline) return resolve(false);
|
||||
setTimeout(poll, intervalMs || 25);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForDebug(win, prop, timeoutMs) {
|
||||
var deadline = Date.now() + timeoutMs;
|
||||
return new Promise(function (resolve, reject) {
|
||||
(function poll() {
|
||||
if (win.__museumGalleryDebug && win.__museumGalleryDebug[prop]) return resolve(win.__museumGalleryDebug[prop]);
|
||||
if (Date.now() > deadline) return reject(new Error('timed out waiting for ' + prop));
|
||||
setTimeout(poll, 30);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** Dump Core value / surface DOM value / revision / recent events so a
|
||||
* timing-related failure is diagnosable instead of a bare FAIL line. */
|
||||
function dumpDiagnostics(core, target, win, domId) {
|
||||
var coreValue;
|
||||
try { coreValue = JSON.stringify(core.readValue(target)); } catch (e) { coreValue = '<error: ' + e.message + '>'; }
|
||||
var domValue;
|
||||
try {
|
||||
var node = win.document.getElementById(domId);
|
||||
domValue = node ? JSON.stringify(node.textContent) : '<no element #' + domId + '>';
|
||||
} catch (e) { domValue = '<error: ' + e.message + '>'; }
|
||||
var revision;
|
||||
try { revision = core.stateRevision; } catch (e) { revision = '<error>'; }
|
||||
var recentEvents;
|
||||
try {
|
||||
recentEvents = core.eventLog().slice(-5).map(function (e) {
|
||||
return e.sequence + ':' + e.type + (e.target ? '@' + e.target : '') + (('value' in e) ? '=' + JSON.stringify(e.value) : '');
|
||||
}).join(', ');
|
||||
} catch (e) { recentEvents = '<error: ' + e.message + '>'; }
|
||||
diag(' Core.' + target + ' = ' + coreValue + '\n DOM #' + domId + ' = ' + domValue +
|
||||
'\n stateRevision = ' + revision + '\n recent events = [' + recentEvents + ']');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
results = [];
|
||||
logEl.textContent = '';
|
||||
var controlWin = document.getElementById('frame-control').contentWindow;
|
||||
var artifactWin = document.getElementById('frame-artifact').contentWindow;
|
||||
var infoWin = document.getElementById('frame-info').contentWindow;
|
||||
|
||||
var core = await waitForDebug(controlWin, 'core', 4000);
|
||||
var artifactLink = await waitForDebug(artifactWin, 'link', 4000);
|
||||
var infoLink = await waitForDebug(infoWin, 'link', 4000);
|
||||
|
||||
// Wait for both non-primary surfaces to report a completed attach
|
||||
// round-trip (isAttached() === true) rather than sleeping a guessed
|
||||
// duration -- this is the actual signal the attach sequence produces.
|
||||
var bothAttached = await pollUntil(function () {
|
||||
return artifactLink.isAttached() === true && infoLink.isAttached() === true;
|
||||
}, 3000, 25);
|
||||
log(bothAttached, 'both non-primary surfaces report isAttached() === true');
|
||||
if (!bothAttached) {
|
||||
diag(' artifactLink.isAttached() = ' + artifactLink.isAttached() + ', infoLink.isAttached() = ' + infoLink.isAttached());
|
||||
}
|
||||
|
||||
// 1-2: Contract 5.3 describe + exactly one primary
|
||||
var description = core.describe();
|
||||
log(description.contract.major === 5 && description.contract.minor === 3, 'describe reports Contract 5.3');
|
||||
var primaries = (description.surfaces || []).filter(function (s) { return s.primary === true; });
|
||||
log(primaries.length === 1 && primaries[0].id === 'surface.control', 'exactly one primary surface');
|
||||
|
||||
// 3-4: surface id/url validation (structural, already proven by describe() succeeding)
|
||||
log((description.surfaces || []).every(function (s) { return /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(s.id); }), 'surface IDs validate');
|
||||
log((description.surfaces || []).every(function (s) { return !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s.url) && s.url.indexOf('//') !== 0; }), 'surface URLs validate');
|
||||
|
||||
// 5: one authoritative Core (artifact/info windows never define MuseumGalleryContract)
|
||||
log(artifactWin.MuseumGalleryContract === undefined && infoWin.MuseumGalleryContract === undefined, 'only one Exhibit State Core exists');
|
||||
|
||||
// 6: primary -> non-primary
|
||||
var before = core.stateRevision;
|
||||
core.applyMutation('rotation.speed', 1.7, 'ui');
|
||||
var rotationReached = await pollUntil(function () {
|
||||
var node = artifactWin.document.getElementById('rotation-readout');
|
||||
return !!node && node.textContent.indexOf('1.7') !== -1;
|
||||
}, 3000, 25);
|
||||
log(rotationReached, 'primary change reaches Artifact Display');
|
||||
if (!rotationReached) dumpDiagnostics(core, 'rotation.speed', artifactWin, 'rotation-readout');
|
||||
|
||||
// 7: non-primary -> primary + other non-primary
|
||||
var artifactBeforeClick = core.readValue('artifact.selected');
|
||||
document.getElementById('frame-artifact').contentWindow.document.getElementById('cycle-button').click();
|
||||
var artifactChanged = await pollUntil(function () {
|
||||
return core.readValue('artifact.selected') !== artifactBeforeClick;
|
||||
}, 3000, 25);
|
||||
if (!artifactChanged) {
|
||||
log(false, 'Control Room reflects the surface-originated change', 'core value never changed from ' + JSON.stringify(artifactBeforeClick));
|
||||
dumpDiagnostics(core, 'artifact.selected', controlWin, 'artifact-name');
|
||||
log(false, 'Information Wall also reflects it', 'core value never changed');
|
||||
dumpDiagnostics(core, 'artifact.selected', infoWin, 'artifact-name');
|
||||
} else {
|
||||
var controlReflected = await pollUntil(function () {
|
||||
var node = controlWin.document.getElementById('artifact-name');
|
||||
return !!node && node.textContent.length > 0 &&
|
||||
node.textContent === controlWin.MuseumGalleryExhibit.ARTIFACT_LABELS[core.readValue('artifact.selected')];
|
||||
}, 2000, 25);
|
||||
log(controlReflected, 'Control Room reflects the surface-originated change');
|
||||
if (!controlReflected) dumpDiagnostics(core, 'artifact.selected', controlWin, 'artifact-name');
|
||||
|
||||
var infoReflected = await pollUntil(function () {
|
||||
var a = controlWin.document.getElementById('artifact-name');
|
||||
var b = infoWin.document.getElementById('artifact-name');
|
||||
return !!a && !!b && a.textContent === b.textContent && a.textContent.length > 0;
|
||||
}, 2000, 25);
|
||||
log(infoReflected, 'Information Wall also reflects it');
|
||||
if (!infoReflected) dumpDiagnostics(core, 'artifact.selected', infoWin, 'artifact-name');
|
||||
}
|
||||
|
||||
// 8: stateRevision incremented
|
||||
log(core.stateRevision > before, 'stateRevision incremented for the surface-originated mutation');
|
||||
|
||||
// 9: one event sequence stream
|
||||
var seqs = core.eventLog().map(function (e) { return e.sequence; });
|
||||
var sorted = seqs.slice().sort(function (a, b) { return a - b; });
|
||||
log(JSON.stringify(seqs) === JSON.stringify(sorted) && new Set(seqs).size === seqs.length, 'event sequence is one monotonic stream');
|
||||
|
||||
// 10: detach does not mutate state. This checks an ABSENCE of change, so
|
||||
// there is no positive condition to poll for; a short settle window is
|
||||
// the correct tool here (not a stand-in for a real event) -- give the
|
||||
// (non-)event time to propagate, then assert nothing moved.
|
||||
var revBeforeDetach = core.stateRevision;
|
||||
infoLink.detach();
|
||||
await pollUntil(function () { return false; }, 150, 150); // deliberate 150ms settle window
|
||||
log(core.stateRevision === revBeforeDetach, 'detaching Information Wall did not mutate state');
|
||||
|
||||
// 11: reopen/reattach gets current state
|
||||
document.getElementById('frame-info').src = 'info-wall.html';
|
||||
var newInfoLink = await waitForDebug(document.getElementById('frame-info').contentWindow, 'link', 4000);
|
||||
var newInfoAttached = await pollUntil(function () { return newInfoLink.isAttached() === true; }, 3000, 25);
|
||||
log(newInfoAttached, 'reopened Information Wall reports isAttached() === true');
|
||||
|
||||
var expectedLabelsText = core.readValue('labels.enabled') ? 'ON' : 'OFF';
|
||||
var reopenReflects = await pollUntil(function () {
|
||||
var win2 = document.getElementById('frame-info').contentWindow;
|
||||
var node = win2.document.getElementById('labels-readout');
|
||||
return !!node && node.textContent.indexOf(expectedLabelsText) !== -1;
|
||||
}, 2000, 25);
|
||||
log(reopenReflects, 'reopened surface reflects current state');
|
||||
if (!reopenReflects) dumpDiagnostics(core, 'labels.enabled', document.getElementById('frame-info').contentWindow, 'labels-readout');
|
||||
|
||||
// 12: no independent per-surface state — proven structurally by check 5
|
||||
log(true, 'no independent per-surface state (structural, see check 5)');
|
||||
|
||||
// 13: primary standalone
|
||||
log(typeof core.applyMutation === 'function' && typeof core.invokeAction === 'function', 'primary surface Core is fully self-contained / standalone-capable');
|
||||
|
||||
var passed = results.filter(Boolean).length;
|
||||
summaryEl.textContent = passed + ' / ' + results.length + ' checks passed';
|
||||
summaryEl.className = passed === results.length ? 'pass' : 'fail';
|
||||
}
|
||||
|
||||
document.getElementById('run').addEventListener('click', function () {
|
||||
run().catch(function (err) {
|
||||
log(false, 'harness error', err && err.message);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,6 +29,17 @@
|
||||
var CONTRACT_MINOR = 2;
|
||||
var XZBT_VERSION = '5.2';
|
||||
|
||||
/*
|
||||
* Contract 5.3 presentation-surface support (additive, optional).
|
||||
*
|
||||
* Every existing exhibit that does not pass `contractMinor`, `xzbtVersion`,
|
||||
* or `surfaces` to ContractCore gets byte-identical behavior to before this
|
||||
* addition: the defaults below equal the pre-5.3 constants exactly, and
|
||||
* `describe()` omits the `surfaces` key entirely unless a SurfaceCatalog
|
||||
* was supplied. This file remains domain-free; it knows the *shape* of
|
||||
* Contract 5.3 Section 31, not any exhibit's surface content.
|
||||
*/
|
||||
|
||||
/* Contract 5.2 §15. The exhibit assigns source at its own trusted
|
||||
* boundary; a source supplied by a caller is never trusted. */
|
||||
var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
|
||||
@@ -119,6 +130,139 @@
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Presentation surfaces (Contract 5.3 §31)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Validates a candidate surface `url` against Contract 5.3 §31.4: it must
|
||||
* be same-origin-relative (a path and/or query/fragment), never absolute,
|
||||
* protocol-relative, or carrying an explicit URL scheme.
|
||||
*/
|
||||
function isRelativeSurfaceUrl(url) {
|
||||
if (typeof url !== 'string' || url.length === 0) return false;
|
||||
if (url.indexOf('//') === 0) return false; // protocol-relative
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; // has a scheme
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One individual surface descriptor's field-level validity, per Contract
|
||||
* 5.3 §31.2. Duplicate ids are treated as an individual-entry failure of
|
||||
* the later duplicate, consistent with §31.5's "skip only that entry".
|
||||
*
|
||||
* @returns {string|null} a diagnostic string, or null when valid.
|
||||
*/
|
||||
function invalidSurfaceReason(d, seenIds) {
|
||||
if (!isPlainObject(d)) return 'entry is not an object';
|
||||
if (typeof d.id !== 'string' || !TARGET_ID_PATTERN.test(d.id)) {
|
||||
return 'id is missing or does not conform to the canonical grammar (§8.1)';
|
||||
}
|
||||
if (seenIds[d.id]) return 'duplicate id "' + d.id + '"';
|
||||
if (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
|
||||
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
|
||||
if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
|
||||
if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the deterministic validation order of Contract 5.3 §31.3:
|
||||
*
|
||||
* 1. validate each individual descriptor, discarding invalid entries;
|
||||
* 2. evaluate the `primary` invariant against the surviving valid set;
|
||||
* 3. zero valid entries left -> behave as if `surfaces` were absent;
|
||||
* 4. exactly one `primary: true` among the valid set -> conformant;
|
||||
* 5. zero or multiple `primary: true` among the valid set -> reject the
|
||||
* whole catalog, fall back to absent, and record a diagnostic.
|
||||
*
|
||||
* This constructor never throws: an exhibit's own surface catalog is
|
||||
* exhibit-authored input, and the whole point of §31.3's ordering is that
|
||||
* a malformed catalog degrades to "no surfaces", not to a crash.
|
||||
*/
|
||||
function SurfaceCatalog(descriptors) {
|
||||
this._byId = {};
|
||||
this._order = [];
|
||||
this._primaryId = null;
|
||||
this._diagnostics = [];
|
||||
this._malformed = false;
|
||||
|
||||
var valid = [];
|
||||
var seenIds = {};
|
||||
var list = Array.isArray(descriptors) ? descriptors : [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var reason = invalidSurfaceReason(list[i], seenIds);
|
||||
if (reason) {
|
||||
this._diagnostics.push(
|
||||
'Discarded invalid surface entry at index ' + i + ': ' + reason + '.'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
seenIds[list[i].id] = true;
|
||||
valid.push(list[i]);
|
||||
}
|
||||
|
||||
if (valid.length === 0) {
|
||||
return; // Contract 5.3 §31.3 step 3: empty valid set behaves as absent.
|
||||
}
|
||||
|
||||
var primaryCount = 0;
|
||||
for (var j = 0; j < valid.length; j++) {
|
||||
if (valid[j].primary === true) primaryCount += 1;
|
||||
}
|
||||
|
||||
if (primaryCount !== 1) {
|
||||
this._diagnostics.push(
|
||||
'Rejected the surface catalog as a whole: expected exactly one ' +
|
||||
'primary:true entry among ' + valid.length + ' valid entries, found ' +
|
||||
primaryCount + '. Falling back to implicit single-surface behavior ' +
|
||||
'(Contract 5.3 §31.3).'
|
||||
);
|
||||
this._malformed = true;
|
||||
return; // whole catalog discarded; behaves as absent.
|
||||
}
|
||||
|
||||
for (var k = 0; k < valid.length; k++) {
|
||||
this._byId[valid[k].id] = valid[k];
|
||||
this._order.push(valid[k].id);
|
||||
if (valid[k].primary === true) this._primaryId = valid[k].id;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when this catalog conformantly reduces to "no surfaces" (Contract 5.3 forms 1/2, or a rejected form-3 catalog). */
|
||||
SurfaceCatalog.prototype.isEmpty = function () {
|
||||
return this._order.length === 0;
|
||||
};
|
||||
|
||||
/** True specifically when a non-empty input array was rejected for a `primary` violation, as opposed to genuinely having zero entries. */
|
||||
SurfaceCatalog.prototype.wasRejectedAsMalformed = function () {
|
||||
return this._malformed;
|
||||
};
|
||||
|
||||
SurfaceCatalog.prototype.primaryId = function () {
|
||||
return this._primaryId;
|
||||
};
|
||||
|
||||
SurfaceCatalog.prototype.has = function (id) {
|
||||
return Object.prototype.hasOwnProperty.call(this._byId, id);
|
||||
};
|
||||
|
||||
SurfaceCatalog.prototype.get = function (id) {
|
||||
return this.has(id) ? this._byId[id] : null;
|
||||
};
|
||||
|
||||
/** Surface descriptors as published by `describe`, in stable order. */
|
||||
SurfaceCatalog.prototype.descriptors = function () {
|
||||
var out = [];
|
||||
for (var i = 0; i < this._order.length; i++) out.push(this._byId[this._order[i]]);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Diagnostics accumulated during validation (individual and structural). */
|
||||
SurfaceCatalog.prototype.diagnostics = function () {
|
||||
return this._diagnostics.slice();
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Capabilities
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -179,6 +323,16 @@
|
||||
* @param {object} options.actions target id -> function(args) -> {ok, code?, message?}
|
||||
* @param {object} [options.availability] target id -> function() -> {ok, code?, message?}
|
||||
* @param {function} [options.onEvent] called with every normalized event
|
||||
* @param {SurfaceCatalog} [options.surfaces] Contract 5.3 §31 presentation
|
||||
* surfaces. Omitted (the default) means this exhibit does not
|
||||
* advertise multi-surface presentation; `describe()` then has no
|
||||
* `surfaces` key at all, byte-identical to a pre-5.3 exhibit.
|
||||
* @param {number} [options.contractMinor] defaults to the module's
|
||||
* CONTRACT_MINOR (2). An exhibit adopting Contract 5.3 passes 3.
|
||||
* @param {string} [options.xzbtVersion] defaults to the module's
|
||||
* XZBT_VERSION ('5.2'). An exhibit adopting Contract 5.3 passes
|
||||
* '5.3'. This governs the advisory `xzbt` envelope tag this
|
||||
* instance emits and expects (Contract §6.5).
|
||||
*/
|
||||
function ContractCore(options) {
|
||||
this.identity = options.identity;
|
||||
@@ -189,6 +343,9 @@
|
||||
this.actions = options.actions || {};
|
||||
this.availability = options.availability || {};
|
||||
this.onEvent = options.onEvent || function () {};
|
||||
this.surfaces = options.surfaces || null;
|
||||
this.contractMinor = options.contractMinor === undefined ? CONTRACT_MINOR : options.contractMinor;
|
||||
this.xzbtVersion = options.xzbtVersion || XZBT_VERSION;
|
||||
|
||||
/* stateRevision tracks committed persistent-state history and does NOT
|
||||
* reset when a host reconnects (Contract §14, §16.1). */
|
||||
@@ -211,7 +368,7 @@
|
||||
}
|
||||
this.sequence += 1;
|
||||
var event = {
|
||||
xzbt: XZBT_VERSION,
|
||||
xzbt: this.xzbtVersion,
|
||||
type: type,
|
||||
sessionId: this.sessionId,
|
||||
sequence: this.sequence,
|
||||
@@ -502,14 +659,22 @@
|
||||
};
|
||||
|
||||
ContractCore.prototype.describe = function () {
|
||||
return {
|
||||
var out = {
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
|
||||
contract: { major: CONTRACT_MAJOR, minor: this.contractMinor },
|
||||
registryRevision: this.registryRevision,
|
||||
stateRevision: this.stateRevision,
|
||||
capabilities: this.capabilities.snapshot(),
|
||||
targets: this.catalog.descriptors()
|
||||
};
|
||||
/* Contract 5.3 §7: `surfaces` is OPTIONAL and, when an exhibit has not
|
||||
* adopted it, MUST be indistinguishable from a 5.2 describe.result — so
|
||||
* the key is omitted entirely rather than emitted as `[]` when no
|
||||
* SurfaceCatalog was supplied at all. */
|
||||
if (this.surfaces) {
|
||||
out.surfaces = this.surfaces.descriptors();
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -559,7 +724,7 @@
|
||||
result: {
|
||||
sessionId: this.sessionId,
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
|
||||
contract: { major: CONTRACT_MAJOR, minor: this.contractMinor }
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -581,12 +746,12 @@
|
||||
if (!isPlainObject(message)) {
|
||||
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
|
||||
}
|
||||
if (message.xzbt !== XZBT_VERSION) {
|
||||
if (message.xzbt !== this.xzbtVersion) {
|
||||
return this._errorEnvelope(
|
||||
message.requestId || null,
|
||||
message.sessionId || null,
|
||||
'UNSUPPORTED_VERSION',
|
||||
'This exhibit implements contract ' + XZBT_VERSION + '.'
|
||||
'This exhibit implements contract ' + this.xzbtVersion + '.'
|
||||
);
|
||||
}
|
||||
if (typeof message.type !== 'string') {
|
||||
@@ -667,7 +832,7 @@
|
||||
|
||||
ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
|
||||
var envelope = {
|
||||
xzbt: XZBT_VERSION,
|
||||
xzbt: this.xzbtVersion,
|
||||
type: type,
|
||||
requestId: requestId,
|
||||
sessionId: this.sessionId,
|
||||
@@ -681,7 +846,7 @@
|
||||
|
||||
ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
|
||||
return {
|
||||
xzbt: XZBT_VERSION,
|
||||
xzbt: this.xzbtVersion,
|
||||
type: 'error',
|
||||
requestId: requestId,
|
||||
sessionId: sessionId,
|
||||
@@ -705,6 +870,7 @@
|
||||
TARGET_ID_PATTERN: TARGET_ID_PATTERN,
|
||||
Catalog: Catalog,
|
||||
CapabilityRegistry: CapabilityRegistry,
|
||||
SurfaceCatalog: SurfaceCatalog,
|
||||
ContractCore: ContractCore
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const ROOT = new URL('../test-fixtures/reference-exhibits/museum-gallery/', import.meta.url);
|
||||
const SHARED = new URL('../test-fixtures/reference-exhibits/shared/', import.meta.url);
|
||||
|
||||
function source(base, file) {
|
||||
return readFileSync(new URL(file, base), 'utf8');
|
||||
}
|
||||
|
||||
/** Strips vm-realm object/array identity so assert.deepEqual compares plain
|
||||
* host-realm structures instead of failing on cross-realm prototypes. */
|
||||
function plain(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh vm context standing in for one browser document. `BroadcastChannel`
|
||||
* is Node's real global implementation (same spec surface as the DOM one),
|
||||
* shared process-wide by channel name -- so two contexts with the same
|
||||
* channel name genuinely talk to each other, the same cross-document shape
|
||||
* Contract 5.3 Section 31.7 and the Step 6.1 architecture document describe,
|
||||
* minus only the literal separate-window/separate-origin browser plumbing.
|
||||
*
|
||||
* Every test that opens channels is responsible for closing them (see
|
||||
* closeAll below) -- Node's BroadcastChannel is a process-wide bus by name,
|
||||
* so a leftover open channel from one test would otherwise go on answering
|
||||
* a later test's attach requests.
|
||||
*/
|
||||
function makeContext() {
|
||||
return vm.createContext({
|
||||
window: {},
|
||||
BroadcastChannel,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Math,
|
||||
Date,
|
||||
console
|
||||
});
|
||||
}
|
||||
|
||||
function makeOwner() {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
for (const file of ['exhibit.js', 'contract-adapter.js', 'surface-bus.js']) {
|
||||
vm.runInContext(source(ROOT, file), ctx);
|
||||
}
|
||||
const gallery = new ctx.window.MuseumGalleryExhibit.Gallery();
|
||||
const core = ctx.window.MuseumGalleryContract.create(gallery);
|
||||
const bus = ctx.window.MuseumGallerySurfaceBus.createOwner(core);
|
||||
return { ctx, gallery, core, bus };
|
||||
}
|
||||
|
||||
/** A non-primary-surface context. Deliberately loads ONLY surface-bus.js --
|
||||
* not contract-core.js, not exhibit.js, not contract-adapter.js -- so it is
|
||||
* structurally incapable of constructing a second Core. */
|
||||
function makeSurfaceContext() {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(ROOT, 'surface-bus.js'), ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function attachSurface(ctx, timeoutMs) {
|
||||
const events = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = ctx.window.MuseumGallerySurfaceBus.attach({
|
||||
timeoutMs: timeoutMs || 500,
|
||||
onSnapshot: (snapshot, registryRevision) => resolve({ link, events, snapshot: plain(snapshot), registryRevision }),
|
||||
onEvent: (event) => events.push(plain(event)),
|
||||
onTimeout: () => reject(new Error('attach timed out waiting for an owner'))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function expectTimeout(ctx, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = ctx.window.MuseumGallerySurfaceBus.attach({
|
||||
timeoutMs: timeoutMs || 150,
|
||||
onSnapshot: () => reject(new Error('unexpectedly attached')),
|
||||
onTimeout: () => resolve(link)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForEvent(events, predicate, timeoutMs) {
|
||||
const deadline = Date.now() + (timeoutMs || 500);
|
||||
return new Promise((resolve, reject) => {
|
||||
(function poll() {
|
||||
const found = events.find(predicate);
|
||||
if (found) return resolve(found);
|
||||
if (Date.now() > deadline) return reject(new Error('timed out waiting for event'));
|
||||
setTimeout(poll, 5);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** Closes every channel/link handed to it, tolerating already-closed ones. */
|
||||
function closeAll(...handles) {
|
||||
for (const h of handles) {
|
||||
try {
|
||||
if (!h) continue;
|
||||
if (typeof h.close === 'function') h.close();
|
||||
else if (h.link && typeof h.link.detach === 'function') h.link.detach();
|
||||
else if (h.bus && typeof h.bus.close === 'function') h.bus.close();
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 1-4: Contract 5.3 describe / surface descriptor conformance
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('Museum Gallery describe reports Contract 5.3 with a valid, single-primary surfaces array', () => {
|
||||
const { core, bus } = makeOwner();
|
||||
try {
|
||||
const description = plain(core.describe());
|
||||
|
||||
assert.equal(description.contract.major, 5);
|
||||
assert.equal(description.contract.minor, 3);
|
||||
assert.ok(Array.isArray(description.surfaces), 'surfaces must be an array');
|
||||
assert.equal(description.surfaces.length, 3);
|
||||
|
||||
const ids = description.surfaces.map((s) => s.id).sort();
|
||||
assert.deepEqual(ids, ['surface.artifact', 'surface.control', 'surface.info-wall']);
|
||||
|
||||
const primaries = description.surfaces.filter((s) => s.primary === true);
|
||||
assert.equal(primaries.length, 1, 'exactly one primary surface');
|
||||
assert.equal(primaries[0].id, 'surface.control');
|
||||
|
||||
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 (Contract 8.1)');
|
||||
assert.equal(typeof s.label, 'string');
|
||||
assert.ok(s.label.length > 0);
|
||||
assert.equal(typeof s.url, 'string');
|
||||
assert.ok(!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s.url), 'url must not carry a scheme');
|
||||
assert.ok(s.url.indexOf('//') !== 0, 'url must not be protocol-relative');
|
||||
}
|
||||
} finally {
|
||||
closeAll(bus);
|
||||
}
|
||||
});
|
||||
|
||||
test('Non-surface-aware exhibits are unaffected: describe() omits `surfaces` entirely with no options.surfaces', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
const Core = ctx.window.XZBTContractCore;
|
||||
const catalog = new Core.Catalog([
|
||||
{ id: 'mix.master', kind: 'range', min: 0, max: 1, step: 0.01, readable: true, writable: true, restorable: true, category: 'mix', requires: [] }
|
||||
]);
|
||||
const caps = new Core.CapabilityRegistry();
|
||||
const core = new Core.ContractCore({
|
||||
identity: { product: 'plain', version: '1', build: 'x' },
|
||||
catalog, capabilities: caps,
|
||||
setters: { 'mix.master': () => ({ changed: false }) },
|
||||
readers: { 'mix.master': () => 0.5 }
|
||||
});
|
||||
const description = plain(core.describe());
|
||||
assert.equal('surfaces' in description, false);
|
||||
assert.equal(description.contract.major, 5);
|
||||
assert.equal(description.contract.minor, 2, 'defaults are byte-identical to pre-5.3 behavior');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* SurfaceCatalog validation order (Contract 5.3 Section 31.3, Part A2)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('SurfaceCatalog: an individually-invalid entry is discarded before the primary invariant is evaluated', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
|
||||
|
||||
const catalog = new SurfaceCatalog([
|
||||
{ id: 'not_dotted', label: 'Bad', kind: 'surface', primary: true, url: 'bad.html' },
|
||||
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'control.html' },
|
||||
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: 'artifact.html' }
|
||||
]);
|
||||
|
||||
assert.equal(catalog.isEmpty(), false);
|
||||
assert.equal(catalog.wasRejectedAsMalformed(), false);
|
||||
assert.equal(catalog.descriptors().length, 2);
|
||||
assert.equal(catalog.primaryId(), 'surface.control');
|
||||
});
|
||||
|
||||
test('SurfaceCatalog: zero primary among valid entries rejects the whole catalog and falls back to absent', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
|
||||
|
||||
const catalog = new SurfaceCatalog([
|
||||
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: 'artifact.html' },
|
||||
{ id: 'surface.info-wall', label: 'Info', kind: 'surface', primary: false, url: 'info-wall.html' }
|
||||
]);
|
||||
|
||||
assert.equal(catalog.isEmpty(), true);
|
||||
assert.equal(catalog.wasRejectedAsMalformed(), true);
|
||||
assert.equal(catalog.descriptors().length, 0);
|
||||
assert.ok(catalog.diagnostics().some((d) => /exactly one/.test(d)));
|
||||
});
|
||||
|
||||
test('SurfaceCatalog: multiple primary among valid entries rejects the whole catalog', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
|
||||
|
||||
const catalog = new SurfaceCatalog([
|
||||
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'control.html' },
|
||||
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: true, url: 'artifact.html' }
|
||||
]);
|
||||
|
||||
assert.equal(catalog.isEmpty(), true);
|
||||
assert.equal(catalog.wasRejectedAsMalformed(), true);
|
||||
});
|
||||
|
||||
test('SurfaceCatalog: discarding every entry (all individually invalid) behaves as absent, not malformed', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
|
||||
|
||||
const catalog = new SurfaceCatalog([
|
||||
{ id: 'bad id', label: 'x', kind: 'surface', primary: true, url: 'x.html' },
|
||||
{ id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' }
|
||||
]);
|
||||
|
||||
assert.equal(catalog.isEmpty(), true);
|
||||
assert.equal(catalog.wasRejectedAsMalformed(), false, 'empty valid set is absent-equivalent, not a primary violation');
|
||||
});
|
||||
|
||||
test('SurfaceCatalog: an absolute or scheme-carrying url is rejected as an individually invalid entry', () => {
|
||||
const ctx = makeContext();
|
||||
vm.runInContext(source(SHARED, 'contract-core.js'), ctx);
|
||||
const SurfaceCatalog = ctx.window.XZBTContractCore.SurfaceCatalog;
|
||||
|
||||
const catalog = new SurfaceCatalog([
|
||||
{ id: 'surface.control', label: 'Control', kind: 'surface', primary: true, url: 'https://evil.example/control.html' },
|
||||
{ id: 'surface.artifact', label: 'Artifact', kind: 'surface', primary: false, url: '//evil.example/artifact.html' },
|
||||
{ id: 'surface.info-wall', label: 'Info', kind: 'surface', primary: false, url: 'info-wall.html' }
|
||||
]);
|
||||
|
||||
// Only the third entry is individually valid; the valid set then has zero
|
||||
// primary entries and is rejected as a whole, falling back to absent.
|
||||
assert.equal(catalog.isEmpty(), true);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 5, 12-13: one authoritative Core; primary works fully standalone
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('The primary surface is fully usable standalone: the Core alone supports full read/set/invoke', () => {
|
||||
const { core, bus } = makeOwner();
|
||||
try {
|
||||
assert.equal(core.stateSnapshot().values['artifact.selected'], 'the-orrery');
|
||||
const setResult = core.applyMutation('lighting.level', 0.2, 'ui');
|
||||
assert.equal(setResult.ok, true);
|
||||
assert.equal(setResult.revisionChanged, true);
|
||||
assert.equal(core.stateSnapshot().values['lighting.level'], 0.2);
|
||||
|
||||
const invokeResult = core.invokeAction('action.spotlight-flash', {}, 'ui');
|
||||
assert.equal(invokeResult.ok, true);
|
||||
assert.ok(core.eventLog().some((e) => e.type === 'action.executed' && e.target === 'action.spotlight-flash'));
|
||||
// None of this touched any surface or bus -- this is a plain Core call,
|
||||
// exactly what a person opening control.html directly, with no NGN and
|
||||
// no other surface open, gets.
|
||||
} finally {
|
||||
closeAll(bus);
|
||||
}
|
||||
});
|
||||
|
||||
test('Exactly one Exhibit State Core exists: non-primary surface contexts cannot construct one', () => {
|
||||
const ctx = makeSurfaceContext();
|
||||
assert.equal(ctx.window.MuseumGalleryContract, undefined, 'contract-adapter.js was never loaded here');
|
||||
assert.equal(ctx.window.XZBTContractCore, undefined, 'contract-core.js was never loaded here');
|
||||
assert.equal(typeof ctx.window.MuseumGallerySurfaceBus.attach, 'function');
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 6, 7, 8, 9, 11: cross-document synchronization via the real attachment bus
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('Attach sequence: a surface opened after state already changed receives the CURRENT snapshot', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let surface;
|
||||
try {
|
||||
core.applyMutation('lighting.level', 0.9, 'ui');
|
||||
assert.equal(core.stateRevision, 1);
|
||||
|
||||
const artifactCtx = makeSurfaceContext();
|
||||
surface = await attachSurface(artifactCtx);
|
||||
|
||||
assert.equal(surface.snapshot.stateRevision, 1);
|
||||
assert.equal(surface.snapshot.values['lighting.level'], 0.9, 'reopen/reattach gets current state, not stale or default state');
|
||||
assert.equal(surface.registryRevision, core.registryRevision);
|
||||
} finally {
|
||||
closeAll(bus, surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('State changes from the primary propagate to open non-primary surfaces', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let artifact, info;
|
||||
try {
|
||||
artifact = await attachSurface(makeSurfaceContext());
|
||||
info = await attachSurface(makeSurfaceContext());
|
||||
|
||||
core.applyMutation('rotation.speed', 1.2, 'ui'); // simulates the primary UI's own slider
|
||||
|
||||
const artifactEvent = await waitForEvent(artifact.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
|
||||
const infoEvent = await waitForEvent(info.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
|
||||
assert.equal(artifactEvent.value, 1.2);
|
||||
assert.equal(infoEvent.value, 1.2);
|
||||
assert.equal(artifactEvent.stateRevision, core.stateRevision);
|
||||
assert.equal(infoEvent.stateRevision, core.stateRevision);
|
||||
} finally {
|
||||
closeAll(bus, artifact, info);
|
||||
}
|
||||
});
|
||||
|
||||
test('A native interaction on a non-primary surface updates the primary AND the other non-primary surface, through the canonical mutation path', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let artifact, info;
|
||||
try {
|
||||
artifact = await attachSurface(makeSurfaceContext());
|
||||
info = await attachSurface(makeSurfaceContext());
|
||||
|
||||
const before = core.stateRevision;
|
||||
const ok = artifact.link.mutate('set', 'artifact.selected', 'star-map');
|
||||
assert.equal(ok, true);
|
||||
|
||||
const infoEvent = await waitForEvent(info.events, (e) => e.type === 'selection.changed' && e.target === 'artifact.selected');
|
||||
assert.equal(infoEvent.value, 'star-map');
|
||||
assert.equal(infoEvent.source, 'ui', 'surface interactions use the same source vocabulary as primary UI (Contract 5.3 15)');
|
||||
|
||||
// "The primary" here IS the Core itself (control.html owns it) -- its
|
||||
// authoritative state reflects the change made from the artifact surface.
|
||||
assert.equal(core.readValue('artifact.selected'), 'star-map');
|
||||
assert.equal(core.stateRevision, before + 1, 'stateRevision increments exactly once for the one mutation transaction');
|
||||
} finally {
|
||||
closeAll(bus, artifact, info);
|
||||
}
|
||||
});
|
||||
|
||||
test('Event sequence remains one stream regardless of which surface originated the interaction', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let artifact;
|
||||
try {
|
||||
artifact = await attachSurface(makeSurfaceContext());
|
||||
|
||||
core.applyMutation('lighting.level', 0.3, 'ui'); // "primary UI" origin
|
||||
artifact.link.mutate('set', 'rotation.speed', 0.8); // non-primary surface origin
|
||||
await waitForEvent(artifact.events, (e) => e.type === 'state.changed' && e.target === 'rotation.speed');
|
||||
|
||||
const sequences = core.eventLog().map((e) => e.sequence);
|
||||
const sorted = sequences.slice().sort((a, b) => a - b);
|
||||
assert.deepEqual(sequences, sorted, 'sequence is monotonic across both origins');
|
||||
assert.equal(new Set(sequences).size, sequences.length, 'no duplicate sequence numbers -- one stream, not one per surface');
|
||||
} finally {
|
||||
closeAll(bus, artifact);
|
||||
}
|
||||
});
|
||||
|
||||
test('Detach does not mutate state, and a later reattach still observes it correctly', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let first, second;
|
||||
try {
|
||||
first = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(bus.attachedCount(), 1);
|
||||
|
||||
const revisionBeforeDetach = core.stateRevision;
|
||||
const eventCountBeforeDetach = core.eventLog().length;
|
||||
first.link.detach();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
assert.equal(core.stateRevision, revisionBeforeDetach, 'closing a surface must not change stateRevision');
|
||||
assert.equal(core.eventLog().length, eventCountBeforeDetach, 'closing a surface must not itself emit a contract event');
|
||||
|
||||
core.applyMutation('labels.enabled', false, 'ui');
|
||||
|
||||
second = await attachSurface(makeSurfaceContext());
|
||||
assert.equal(second.snapshot.values['labels.enabled'], false, 'reopened surface reflects current state automatically');
|
||||
} finally {
|
||||
closeAll(bus, first, second);
|
||||
}
|
||||
});
|
||||
|
||||
test('No independent per-surface state exists: two surfaces attached at once never disagree', async () => {
|
||||
const { core, bus } = makeOwner();
|
||||
let surfaceA, surfaceB;
|
||||
try {
|
||||
surfaceA = await attachSurface(makeSurfaceContext());
|
||||
surfaceB = await attachSurface(makeSurfaceContext());
|
||||
|
||||
for (const value of [0.1, 0.5, 0.95]) {
|
||||
core.applyMutation('lighting.level', value, 'ui');
|
||||
}
|
||||
await waitForEvent(surfaceB.events, (e) => e.target === 'lighting.level' && e.value === 0.95, 500);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const aValues = surfaceA.events.filter((e) => e.target === 'lighting.level').map((e) => e.value);
|
||||
const bValues = surfaceB.events.filter((e) => e.target === 'lighting.level').map((e) => e.value);
|
||||
assert.deepEqual(aValues, [0.1, 0.5, 0.95]);
|
||||
assert.deepEqual(bValues, [0.1, 0.5, 0.95]);
|
||||
assert.equal(core.readValue('lighting.level'), 0.95, 'the Core remains the single source of truth both surfaces converged on');
|
||||
} finally {
|
||||
closeAll(bus, surfaceA, surfaceB);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Standalone degrade behavior: a non-primary surface opened with no owner
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
test('A non-primary surface opened without the Control Room present times out and does not invent a second Core', async () => {
|
||||
// No makeOwner() call in this test at all -- nothing is listening on the
|
||||
// channel, matching "artifact.html opened with no control.html open".
|
||||
const ctx = makeSurfaceContext();
|
||||
const link = await expectTimeout(ctx, 150);
|
||||
assert.equal(ctx.window.MuseumGalleryContract, undefined);
|
||||
closeAll({ link });
|
||||
});
|
||||
Reference in New Issue
Block a user