Post Step 4 Completion

This commit is contained in:
2026-09-14 07:57:18 -07:00
parent db973a86f3
commit 446bf3533e
52 changed files with 32034 additions and 3 deletions
+10
View File
@@ -46,3 +46,13 @@ scoped `AGENTS.md` files before making changes.
interactive management of devlog entries and publishing status.
- Run `git diff --check` and review diffs carefully before staging or committing.
- At handoff, summarize files changed, validation performed, and any next steps.
## Authoritative project documents, in priority order:
- XZBT Exhibit Contract Specification v5.2
- XZBT-NGN Exhibit Engine Implementation Plan v5.2
- XZBT Exhibit Authoring Guide v5.2
- Step 3.7 reference exhibit report
- Do not infer XZBT-NGN behavior from SciFi-XZBT source code.
- SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
+68 -1
View File
@@ -1,4 +1,71 @@
# Labyricorn Project Template
# XZBT-NGN Exhibit Engine
A minimal, single-exhibit engineering host for XZBT Exhibit Contract 5.2.
It discovers controls from `describe`, tracks the exhibit's reported state,
and exposes set/invoke operations, events, sessions, and protocol errors.
## Run locally
Requires Node.js 22 or later. No npm dependencies or build step are needed.
```powershell
npm start
```
Open `http://127.0.0.1:4173`. Enter a same-origin exhibit path and select
**Load exhibit**. The host negotiates a real session, calls `describe` and
`state.get`, then renders the discovered catalog. **Reconnect** establishes
a new session without reloading the exhibit; **Refresh state** requests a
new authoritative snapshot. **Disconnect** removes the exhibit frame.
Included test fixture paths:
```text
/test-fixtures/reference-exhibits/aquarium/index.html
/test-fixtures/reference-exhibits/planetarium/index.html
/test-fixtures/reference-exhibits/haunted-house/index.html
```
These copied fixtures have documented compatibility corrections; see
[fixture provenance](test-fixtures/PROVENANCE.md). They are not NGN product
logic. Additional trusted HTML exhibits can be placed in a directory under
`public/` and loaded by their same-origin URL. The server exposes only
`public/`, `src/`, and `test-fixtures/`, and binds to `127.0.0.1`.
Set the `PORT` environment variable to change the default port.
## Verification
```powershell
npm test
python devlog_editor.py --validate
git diff --check
```
With the server running, open
`http://127.0.0.1:4173/test-fixtures/host-verification.html` and select
**Run verification** for repeatable real-browser transport tests against all
three exhibits. Manual admin UI and native exhibit gesture checks are recorded
in [the Step 4 report](STEP4-MVP-REPORT.md).
## Implementation boundaries
- `src/host.js`: session/request correlation, catalog, state cache, events,
synchronization, and control operations.
- `src/validation.js`: descriptor, value, and clarified argument validation.
- `src/transport/post-message.js`: same-origin frame transport only.
- `src/ui.js` and `public/`: descriptor-driven engineering interface.
- `server/serve.js`: loopback static server with explicit served directories.
- `tests/`: focused automated host and server tests.
- `test-fixtures/`: copied reference exhibits and real-browser verification.
The state display is a cache of exhibit reports. Events update reported values;
editable fields are drafts and are submitted only when Set/Invoke is selected.
Draft fields start from the first snapshot and do not overwrite themselves on
each incoming event. Registry/capability rediscovery rebuilds the controls.
Logs retain the latest 200 entries in memory. The interface intentionally has
no persistence, accounts, MIDI, scenarios, webhooks, packaging, or orchestration.
## Template and publishing records
A starter template and baseline structure for projects compatible with
[Labyricorn](https://www.labyricorn.com).
+149
View File
@@ -0,0 +1,149 @@
# Step 4 MVP — Independent Conformance Review
**Reviewer:** independent (not the implementing agent)
**Date:** 2026-09-14
**Scope:** XZBT-NGN Step 4 MVP — host, transport, validation, UI, server, tests, and the three copied reference fixtures — against XZBT Exhibit Contract 5.2 and the NGN Implementation Plan v5.2.
**Method:** read-only. No file in either repository was modified. Every claim below is tied to a command result or a file line.
---
## 1. Verdict
**The Step 4 MVP is a genuine, working implementation of the Contract 5.2 host boundary, and its central genericity claim holds.**
The host is real, not a mock: it negotiates a session, discovers a catalog it has never seen, renders controls from descriptors, drives state and impulses, tracks two independent counters, and recovers from desynchronization. I reproduced the automated suite at 20/20 passing. I independently confirmed that no product source file contains any exhibit-domain vocabulary.
The report's own verdict — `STEP 4 MVP COMPLETE — MINOR FOLLOW-UP REQUIRED` — is **accurate and appropriately hedged**. I found no defect that invalidates the MVP. I found **one substantive conformance gap in the fixture layer** (F-1), **one host-side robustness gap** (F-2), and a set of documentation and evidence-scope issues.
The most important finding is not a bug. It is that **the fixture repairs are load-bearing for the genericity claim, and the report does not say so.** The three fixtures are the only evidence that the host is generic. Those fixtures were edited to make them conform. That is legitimate — but it means the MVP has demonstrated that the host drives three *repaired* exhibits, not three *unmodified* ones. The report's phrase "a single unmodified host drives all three" is true of the host and false of the fixtures, and the distinction matters for what the MVP has actually proven.
---
## 2. What I verified independently
| Claim | How I checked | Result |
| --- | --- | --- |
| 20 automated tests pass | `node --test` in `G:\.vibe\XZBT-NGN` | **Confirmed.** `tests 20 / pass 20 / fail 0`, 357 ms. |
| No domain vocabulary in product source | `Select-String` over `src/*.js`, `src/transport/*.js`, `public/*.html`, `public/*.css`, `server/*.js` for `aquarium\|planetarium\|haunted\|fish\|apparition\|scifi\|universe\|preset\|warp\|speech\|announcement\|tank\|sky\|room\|telemetry` | **Confirmed.** Zero matches. The only hit for a broader pattern was `host.js:3`, the canonical event-type list. |
| Fixture repairs are exactly as documented | `git diff --no-index` between `G:\.vibe\SciFi-XZBT\exhibit-test\abacusai\reference-exhibits` and `test-fixtures/reference-exhibits` | **Confirmed, and complete.** The entire delta is: 8 × `kind: 'telemetry'` → `kind: 'state'`; 1 × `args:` → `arguments:`; 3 × `sky.magnitudeLimit` → `sky.magnitude-limit`; 32 added lines in `shared/contract-core.js`; and deletion of `host-harness/` and `tests/`. |
| The upstream source was not modified | `git status` in `G:\.vibe\SciFi-XZBT` | **Confirmed.** `exhibit-test/` is untracked; no tracked file in the exhibit repo was touched by this work. |
| The magnitude repair was a real bug, not a cosmetic rename | Compared `read()` and `dispatch()` in both copies | **Confirmed.** Upstream `planetarium/exhibit.js:397` read `case 'sky.magnitudeLimit'` while the descriptor at `contract-adapter.js:36` declared `sky.magnitude-limit`. The reader returned `null` for a declared readable target, and native slider writes fell through to no case. The repair is correct and minimal. |
| The `arguments` repair was necessary | Read `argumentSchema()` in `src/validation.js` and the invoke path in `shared/contract-core.js` | **Confirmed.** Both read `target.arguments`. Upstream Planetarium declared `args:`, so its `minutes` argument was invisible to both the host and the fixture's own validator. |
---
## 3. Findings
### F-1 — Substantive: the fixture invoke path does not enforce `INVALID_ARGUMENTS`
**Severity: medium. Conformance gap in the fixture layer, not in the host.**
Contract §24 lists `INVALID_ARGUMENTS` as a distinct base error code, and the Authoring Guide's conformance checklist (line 522) requires that "unknown target, invalid value, invalid session, and invalid arguments each return the correct distinct error code."
The fixture core's invoke path (`shared/contract-core.js`, the block marked `// Fixture-only correction for the owner's authoritative 5.2 clarification`) returns `INVALID_VALUE` for every argument failure: undeclared key, missing required argument, wrong type, enum miss, bounds, step, and length. `INVALID_ARGUMENTS` appears in the `ERROR_CODES` array at the top of the same file and is never returned by anything.
The host is not at fault. `src/validation.js` validates arguments locally and raises `INVALID_VALUE` before the request leaves the browser, and `src/host.js` faithfully surfaces whatever code the exhibit returns. The host would handle `INVALID_ARGUMENTS` correctly if a fixture emitted it.
The practical consequence is that the MVP has **no end-to-end evidence that a distinct `INVALID_ARGUMENTS` code survives the transport**, because no fixture can produce one. The report's coverage list claims "argument constraints" are covered — true — but does not claim `INVALID_ARGUMENTS` specifically, and it should not, because it is not.
Note also that `planetarium/exhibit.js` *does* return `INVALID_ARGUMENTS` from `advanceTime()` for out-of-range minutes. That path is unreachable through the contract, because the fixture core rejects the value first with `INVALID_VALUE`. So the code exists in the fixture, is declared in the fixture's own error table, and is dead.
**Recommendation:** either return `INVALID_ARGUMENTS` from the fixture core for argument-shape failures (keeping `INVALID_VALUE` for value-constraint failures), or record explicitly in `PROVENANCE.md` and the report that the fixture layer collapses the two codes and that `INVALID_ARGUMENTS` is therefore unverified end-to-end. Do not leave it implicit.
### F-2 — Host: `validateCatalog` does not validate `restorable`, `category`, or `label`
**Severity: low. Robustness, not correctness.**
Contract §9 requires each descriptor to carry `restorable`, `category`, and `requires`, and the Authoring Guide (line 94) repeats the list. `validateCatalog` in `src/validation.js` checks `id`, `kind`, `readable`, `writable`, and `requires` — but not `restorable` or `category`. A descriptor missing either is accepted silently.
This is defensible for an MVP: the host does not currently use `restorable` (no scenario restore yet) or `category` (no grouping in the UI), so an absent field cannot cause a wrong action. But the host is the component that will later drive scenario restore, and `restorable` is exactly the field that decides whether a target belongs in a restore snapshot. Validating it now is cheap and prevents a malformed descriptor from becoming a silent restore bug in Phase 4.
**Recommendation:** add `typeof t.restorable === 'boolean'` and `typeof t.category === 'string'` to the descriptor check in `validateCatalog`. Low priority; safe to defer to the scenario milestone if the owner prefers.
### F-3 — Documentation: the genericity claim is stated more strongly than the evidence supports
**Severity: medium. This is a reporting-accuracy issue, and it is the one I would fix first.**
`test-fixtures/reference-exhibits/README.md` states: "The point of the set is not the exhibits; it is that a single unmodified host drives all three." The host is indeed unmodified and generic — I verified that. But the fixtures are not unmodified, and the README is the upstream document that `PROVENANCE.md` explicitly says "describes upstream verification and is not evidence of NGN verification."
The risk is concrete. A future reader — or a future agent — opening `test-fixtures/reference-exhibits/README.md` sees a claim of three unmodified exhibits driven by one host, and the README's own "Bugs found and fixed during verification" section lists the camelCase-ID bug as already fixed upstream. It is not fixed upstream. The upstream copy at `G:\.vibe\SciFi-XZBT\exhibit-test\abacusai\reference-exhibits` still has `sky.magnitudeLimit` in `read()` and `dispatch()`, still has `kind: 'telemetry'` in eight descriptors, and still has `args:` in Planetarium. Anyone who re-copies the fixtures from the stated source will reintroduce all three defects.
`PROVENANCE.md` is accurate and does list the corrections. The problem is that the README sits in the same directory, is longer, is more confident, and contradicts it.
**Recommendation:** add a short header note to the copied `test-fixtures/reference-exhibits/README.md` stating that this is a locally corrected copy, that the upstream README's verification claims do not apply to it, and pointing at `PROVENANCE.md`. One paragraph. Do not rewrite the upstream document.
### F-4 — Evidence scope: the browser suite is not reproducible from the repository
**Severity: low. Process, not product.**
The report's strongest evidence is the real-browser suite: "3/3 real exhibits passed" via `/test-fixtures/host-verification.html`. I read `test-fixtures/host-verification.js` and it is a real, substantive test — it drives the production `ExhibitHost` and `postMessageTransport` against actual exhibit HTML in iframes, and it covers version rejection, handshake, catalog, state/range/selection writes, no-op revision, impulse execution, argument delivery, error survival, events, reconnect, forced session invalidation, and recovery. That is good evidence and I have no reason to doubt it.
What I cannot do is reproduce it. It requires a human to run `npm start`, open a browser, and click a button. There is no recorded output, no timestamped artifact, and no way for a reviewer to confirm the 3/3 result without re-running it by hand. The report is honest about this ("This is local Chromium evidence, not a cross-browser certification"), which is the right hedge.
**Recommendation:** none required for the MVP. If the owner wants the browser suite to carry weight in future reviews, have it write its result to a file or the console in a form that can be captured. Otherwise, treat the browser suite as supporting evidence and the 20 automated tests as the reproducible baseline — which is how the report already frames it.
### F-5 — Observation: `stateRevision` is not reset on reconnect, and the host depends on that
**Severity: informational. No action needed.**
`shared/contract-core.js` deliberately preserves `stateRevision` across sessions while resetting `sequence`. The host's `refresh()` enforces `snapshot.stateRevision >= baseline` and rejects a regressed snapshot. The browser suite asserts `host.stateRevision >= revisionBeforeReconnect`.
This is correct and matches the README's stated rationale. I flag it only because it is a cross-component invariant that is not written down in the contract itself — Contract §14 says `stateRevision` is monotonic, and §16.1 says `sequence` resets per session, but the contract does not explicitly say what happens to `stateRevision` on a new session. The fixture and host agree; a future exhibit that reset `stateRevision` on handshake would be rejected by the host with "Snapshot revision regressed." Worth a line in the contract's §14 or in the authoring guide.
---
## 4. Contract conformance assessment
Against Contract §29 (Conformance Minimum), for the **host**:
| Requirement | Status |
| --- | --- |
| Compatible handshake | Met. `host.js` sends `supportedContractMajors: [5]`, validates the negotiated major, rejects otherwise. |
| Normalized message envelope | Met. Requests carry `xzbt`, `type`, `requestId`, `sessionId`; responses are matched by `requestId` and expected type. |
| `describe` | Met. `refresh(true)` requests it, `validateCatalog` checks it, the UI renders from it. |
| At least one discoverable target | Met. Catalog is built entirely from `describe`; no hard-coded IDs. |
| `state.get` for readable persistent targets | Met. `refresh()` requests it and enforces that the snapshot contains exactly the readable non-impulse set — both directions. |
| `stateRevision` | Met. Tracked, displayed, gap-detected, and protected against regression. |
| Normalized responses | Met. `responses` map enforces the expected result type per request. |
| Normalized event emission | Met. All six base event types accepted; unknown types rejected. |
| Session event sequencing | Met. Monotonic check, gap detection, resync, and per-session reset. |
| Command validation | Met. `validateSet` and `validateArgs` run before the request is sent. |
| Version reporting | Met. Contract, exhibit identity, registry revision, and state revision are all displayed. |
The host meets the conformance minimum. The two gaps I found (F-1, F-2) are in the fixture layer and in descriptor-field strictness respectively; neither is a conformance-minimum failure.
Against the Implementation Plan §36 (MVP Acceptance Criteria), all twelve criteria are met, including the last one — "no SciFi-specific target vocabulary is hard-coded into core NGN logic" — which I verified directly rather than taking on trust.
---
## 5. Assessment of the report itself
The report is unusually good on the dimension that usually fails: **it separates what was verified from what was not.** Section N names the browser and date and calls the result local evidence. Section Q lists limitations without prompting. Section M states plainly that the fixtures needed repairs. Section O distinguishes the automated suite from the browser suite. The verdict is hedged rather than triumphant.
Three places where it is weaker than it should be:
1. **Section M's framing.** "The copied fixtures needed narrow documented corrections" is accurate but understates the consequence. Those corrections are what makes the genericity demonstration possible. The report should say that the MVP proves the host drives three *repaired* exhibits, and that the repairs are the reason the demonstration works.
2. **Section O's coverage list.** It claims "argument constraints" are covered, which is true, but a reader will reasonably infer that `INVALID_ARGUMENTS` is among them. It is not, and cannot be, given F-1.
3. **Section M's table.** "Aquarium 12 / Planetarium 14 / Haunted House 13" matches the descriptors I counted. Good. But the report does not note that the upstream README claims the same counts while the upstream fixtures would fail to load at all under a strict catalog validator — the camelCase IDs would throw at `Catalog` construction. That is worth one sentence, because it is the clearest evidence that the repairs were necessary rather than cosmetic.
None of these change the verdict. They change how much weight a future reader should put on it.
---
## 6. Recommended follow-up, in priority order
1. **Add the copy-notice header to `test-fixtures/reference-exhibits/README.md`** (F-3). One paragraph. Highest value per unit of effort, because it prevents a future re-copy from silently reintroducing three defects.
2. **Resolve `INVALID_ARGUMENTS`** (F-1) — either implement it in the fixture core or document the collapse explicitly in `PROVENANCE.md`.
3. **Tighten `validateCatalog`** (F-2) to check `restorable` and `category`. Deferrable to the scenario milestone.
4. **Correct the three report framings** in §5 above.
5. **Optional:** note the `stateRevision`-across-sessions invariant in the contract or authoring guide (F-5).
---
## 7. Bottom line
The MVP does what it claims. The host is genuinely generic, the transport genuinely validates origin and source, the state machine genuinely distinguishes revision from sequence, and the recovery paths are genuinely exercised. The fixture repairs were necessary, correctly diagnosed, and minimal — the magnitude repair in particular fixed a real null-read and a real dead native write path, not a cosmetic naming mismatch.
The one thing I would not let stand is the impression that three pristine exhibits were driven by an unmodified host. Three *repaired* exhibits were driven by an unmodified host, and the repairs are load-bearing. That is a fine result for an MVP — it is arguably the more useful result, because it found real bugs in the reference set. It just needs to be said plainly.
+145
View File
@@ -0,0 +1,145 @@
# A. Executive result
The first Step 4 host is implemented and verified against three unrelated reference exhibits using the same production host, transport, and descriptor-driven UI. Twenty automated tests pass. The repeatable real-browser suite passes for all three fixtures.
The owner resolved the initial argument-schema blocker as an authoritative Contract 5.2 clarification. It is now recorded in contract section 9.5. Work stops at the first MVP; no higher-level NGN features were added.
# B. Repository / stack overview
Stack: Node.js 22+ standard library, browser-native JavaScript modules, HTML/CSS, and Node's built-in test runner. There are no npm dependencies or build step. The NGN implementation plan established local hosting and discovery but did not prescribe a framework; this stack meets that scope with minimal tooling.
Run `npm start`, then open `http://127.0.0.1:4173`. The server binds to loopback and serves only `public/`, `src/`, and `test-fixtures/`. An alternate port can be selected through `PORT`.
The existing Labyricorn publishing template remains present. Its exhibition metadata is still template content; publishing it is outside this implementation milestone.
# C. Transport implementation
`src/transport/post-message.js` implements a replaceable send/subscribe/close transport. Only this module knows about Window message events. Incoming traffic must match both the expected frame window and the host's origin; outgoing traffic uses an explicit target origin.
The first milestone loads same-origin HTML exhibits in an iframe. No remote exhibit connector or external filesystem mount is implemented.
# D. Session handling
The host sends hello with supported major 5 and records the actual exhibit-issued session, negotiated major/minor, and product/version/build metadata. Requests have unique correlation IDs, expected response types, and timeouts.
Reconnect cancels old requests, creates a new session, clears session event tracking, and reloads discovery/state. Old-session events are ignored. INVALID_SESSION makes controls unavailable until reconnect. A real-browser test replaces the session at the exhibit, verifies INVALID_SESSION, and reconnects successfully.
# E. Discovery / describe handling
The host validates describe metadata, revisions, target IDs, kinds, capabilities, and kind-specific control metadata. The catalog has no hard-coded target IDs or count. Metadata, raw descriptors, capability snapshots, and registryRevision are visible.
StateRevision is displayed from accepted snapshots and persistent-state events rather than treating the description alone as a complete state snapshot.
# F. Dynamic target rendering
The UI renders numeric inputs from range bounds/step, boolean state checkboxes, supported scalar state inputs, selection options, and impulse buttons. Read-only targets have no write controls. Descriptor text is rendered with textContent.
Impulse arguments follow the owner's array-based `arguments` clarification. Supported types are string, number, integer, and boolean; enum, bounds, step, and length constraints are validated. Optional arguments have explicit include controls. Missing arguments metadata is treated as empty. Unsupported future schemas leave the impulse visible with an explanation and disabled invocation.
# G. State handling
The local Map is a cache of reported exhibit values. It is populated by state.result and updated by state.changed / selection.changed. No requested value is optimistically fabricated.
Snapshots and events validate known value types and constraints. Invalid snapshots preserve the prior cache and mark synchronization uncertain. Regressive snapshots cannot roll back an observed revision. Events received while a snapshot is pending are replayed after that snapshot to avoid dropping intervening changes.
Sequence/revision gaps and malformed events schedule fresh state.get. Multiple target events sharing a transaction revision are accepted. Successful operations refresh state to include other reported consequences. A manual refresh is also available.
# H. Set / invoke behavior
Set requires a writable persistent descriptor. Invoke requires kind impulse regardless of the descriptor's writable flag. Bounds, step, types, selection membership, required arguments, unknown keys, and declared constraints are checked without clamping.
Successful responses do not substitute for exhibit state reports. Protocol errors are displayed. Timed-out operations are treated as uncertain and trigger state resynchronization.
# I. Event handling
All six base event types are accepted: state.changed, selection.changed, action.executed, capability.changed, registry.changed, and error. The event log exposes the complete received envelope, including source/correlation when present.
Event sequence is tracked separately from persistent-state revision. Gaps, duplicates, and out-of-order sequences are diagnosed; older events are not applied. The first observed sequence establishes the new session's baseline. No missing events are invented.
# J. Capability handling
Capability IDs and lifecycle states are displayed exactly as discovered. Unsupported/error requirements disable operations while leaving targets visible. Available/loading states do not universally prove usability, so attempts may receive the exhibit's own unavailability error; this preserves the contract's allowance for fallback behavior.
Because Contract 5.2 does not standardize capability.changed payload fields, the host uses that event to request a fresh describe snapshot instead of interpreting a fixture-specific payload shape.
Real-browser verification observed Haunted House audio as available, a rejected host action, native loading/ready events, a refreshed ready display, and a successful later host action.
# K. Registry-change handling
registry.changed schedules describe plus state.get through a 100 ms debounce. Normal state changes do not rediscover the catalog. Automated tests verify burst coalescing and replacement of targets/cache entries.
The supplied reference exhibits have fixed registries. Dynamic registry changes were tested with a synthetic peer, not claimed as a naturally occurring reference-exhibit browser scenario.
# L. Error handling
The visible protocol console records stable codes and explanatory messages. Covered failures include INVALID_SESSION, INVALID_VALUE, CAPABILITY_UNAVAILABLE, UNSUPPORTED_VERSION, malformed responses/state, timeout, and unsupported schemas.
Local input checks and exhibit-returned rejections are separately exercised. Browser tests bypass local value validation for selected requests to prove that real exhibit errors survive the transport.
# M. Genericity verification
The owner selected the AbacusAI reference set from `G:/.vibe/SciFi-XZBT/exhibit-test/abacusai/reference-exhibits/`. Copies reside under `test-fixtures/reference-exhibits/`; no source repository was modified and no SciFi application source was used to infer host architecture.
| Fixture | Discovered targets | Result |
| --- | ---: | --- |
| Aquarium | 12 | Browser suite and admin UI operations passed |
| Planetarium | 14 | Browser suite and numeric argument operation passed |
| Haunted House | 13 | Browser suite and live capability lifecycle passed |
Product source checks found no aquarium/planetarium/haunted-house/SciFi names or domain target prefixes in `src/`, `public/`, or `server/`. Exhibit selection in the repeatable test harness is test configuration only.
The copied fixtures needed narrow documented corrections: undeclared telemetry kinds became read-only state descriptors; Planetarium's args metadata became arguments; fixture invocation gained clarified argument validation; and Planetarium's stale magnitude ID was aligned with its canonical descriptor. These are local fixture repairs, not host-specific exceptions. See `test-fixtures/PROVENANCE.md`.
# N. Browser/runtime verification
Verified on 2026-09-14 in the Codex in-app Chromium browser with the local Node server.
The repeatable page `/test-fixtures/host-verification.html` reported 3/3 real exhibits passed. It imports the production host and transport and loads actual exhibit HTML, scripts, state, and behavior in iframes. Checks include incompatible major rejection, handshake, catalog, state, representative state/range/selection writes, no-op revision, impulse execution, delivered arguments where declared, error responses, events, reconnect, forced session invalidation, and recovery.
Separate admin UI checks verified:
- Aquarium population 12 to 16, paused false to true, species to Blue Tang, feed invocation, rejected population 41, native species change to Guppy, native resume event, and reconnect with preserved revision.
- Planetarium discovered optional minutes control delivering 30 and azimuth write to 0.75.
- Haunted House unavailable audio action before a gesture, native capability initialization, ready display, and subsequent host invocation.
A screenshot inspection confirmed readable control layout. The browser error/warning log was empty during the checked Haunted House run. This is local Chromium evidence, not a cross-browser certification.
# O. Tests
`npm test`: 20 tests passed, zero failures.
Repository checks: `python devlog_editor.py --validate` reported "devlog is valid";
`git diff --check` passed for tracked changes. New application files remain
untracked until the owner requests staging/commit.
Coverage includes negotiation/correlation, all persistent kinds, argument constraints, no-op revisions, exhibit errors, invalid sessions, unsupported version, timeouts, native-style events, shared revisions, gap detection/resync, debounced rediscovery, snapshot/event races, stale sessions, malformed events, origin/source checks, server boundaries, invalid reported values, regressive snapshots, dynamic catalog replacement, future argument types, and capability gating.
The real-browser suite passed 3/3 actual exhibits and supplements these synthetic unit cases. It is run through the documented browser page rather than npm test. No success claim relies on mocks alone.
# P. Files created/changed
- Added `package.json`.
- Added `server/serve.js`.
- Added `public/index.html` and `public/style.css`.
- Added `src/host.js`, `src/validation.js`, `src/ui.js`, and `src/transport/post-message.js`.
- Added `tests/host.test.js`.
- Added three reference fixture directories and shared dependencies, upstream README, local provenance, and the browser verification HTML/JS.
- Added the owner-authorized section 9.5 clarification to the supplied Contract 5.2 document.
- Updated README with run/test instructions and this report with implementation evidence.
Pre-existing AGENTS.md edits and copied architectural/reference documents were preserved. No commit, push, external exhibit edit, or publishing-record edit was performed.
# Q. Known limitations
This is an engineering MVP: one trusted same-origin exhibit, in-memory state/logs, no saved connection, and no external mount configuration. The UI uses numeric inputs rather than separate range sliders. Draft fields are distinct from reported values and reset on rediscovery.
The fixture copies have compatibility repairs. Their continuously changing derived readings are refreshed from snapshots; this milestone does not certify the upstream exhibits' complete event/revision conformance. SciFi-XZBT and other browsers were not tested.
Reference fixtures do not naturally exercise changing registries or every future argument type; those host paths have focused synthetic tests. No timed idle snapshot polling is enabled; recovery is driven by messages, request timeouts, operations, reconnect, and explicit refresh.
# R. Next Step 4 work
The first MVP is finished. Await owner direction. Reasonable follow-up within Step 4 is independent review of fixture conformance and additional browser/exhibit interoperability testing. Publishing metadata can be authored separately once requested. MIDI, scenarios, webhooks, telemetry acquisition, packaging, and later phases remain deferred.
# S. Final verdict
STEP 4 MVP COMPLETE — MINOR FOLLOW-UP REQUIRED
@@ -0,0 +1,855 @@
# XZBT-NGN Exhibit Engine Implementation Plan
## Version 5.2
**Status:** Architectural implementation plan
**Product:** XZBT-NGN
**Product identity:** XZBT-NGN Exhibit Engine
**Dependency:** XZBT Exhibit Contract 5.2
**Current implementation state:** Not yet implemented
---
## 1. Purpose
XZBT-NGN is the host, orchestration, integration, recording, authoring, administration, and packaging environment for XZBT-compatible exhibits.
It does not replace the exhibit.
It extends it.
Version 5.2 assumes no legacy NGN implementation and treats the XZBT Exhibit Contract as the architectural dependency NGN must follow.
---
## 2. Product Boundary
The intended ecosystem is:
### Free standalone exhibit
A complete independently usable experience.
### Free packaged authored experience
A self-running artifact exported by NGN that may contain scenario data and a compact runtime.
### Premium XZBT-NGN Exhibit Engine
Provides:
- hosting;
- orchestration;
- recording;
- scenario authoring;
- webhooks;
- telemetry connectors;
- external automation;
- administration;
- packaging;
- remote control;
- integrations.
The commercial value comes from added capability, not intentional degradation of free exhibits.
---
## 3. Primary Responsibilities
NGN owns:
- contract session management;
- exhibit discovery;
- capability cataloging;
- state synchronization;
- event monitoring;
- scenario runtime;
- scenario recording;
- timeline authoring;
- webhook endpoints;
- connector framework;
- telemetry mapping;
- automation;
- administration;
- diagnostics;
- packaging;
- distribution support;
- future multi-exhibit orchestration.
---
## 4. Contract-First Rule
NGN MUST interact with exhibits through the XZBT Exhibit Contract target surface.
Direct exhibit-specific escape hatches are development diagnostics, not architecture.
NGN MUST dynamically discover targets.
Core NGN MUST NOT hard-code SciFi-XZBT target vocabulary.
---
## 5. Initial MVP
The first useful NGN proves only the contract boundary.
It should:
1. host or attach to one SciFi-XZBT instance;
2. complete contract handshake;
3. request `describe`;
4. render discovered capabilities and targets;
5. request state;
6. invoke and set selected targets;
7. receive and display events;
8. display sequence and state revision;
9. detect and recover synchronization loss;
10. log errors.
This milestone exists before scenario authoring, recording, webhooks, or connectors.
---
## 6. Exhibit Hosting
NGN SHOULD be able to host compatible HTML exhibits from a stable local origin.
Hosting enables:
- same-origin communication;
- controlled dependency delivery;
- predictable asset paths;
- future local Web3D hosting;
- packaging support;
- browser receiver support.
Hosting is not required for the ordinary standalone artifact.
---
## 7. Session Management
NGN maintains one logical contract session per connected exhibit instance.
Track:
- exhibit identity;
- contract version;
- session ID;
- registry revision;
- state revision;
- expected event sequence;
- capabilities;
- target descriptors;
- current state;
- connection status.
When a new session is established, reset expected event sequence and session-scoped correlation state.
---
## 8. Discovery Catalog
NGN builds its control catalog from `describe`.
Generic UI mapping:
```text
range -> slider / numeric control
state -> state editor / toggle where appropriate
selection -> dropdown / list
impulse -> trigger control
```
`kind: impulse` is sufficient to infer invokability.
Exhibit-provided labels may drive UI display.
Core behavior does not depend on presentation hints.
---
## 9. Registry Revision Handling
NGN records `registryRevision` from `describe`.
On `registry.changed`, NGN MUST schedule a fresh `describe`.
Use a short debounce so a burst of registry changes results in one rediscovery.
Recommended initial debounce:
```text
100 ms
```
If the exhibit uses a fixed registry, ordinary context changes should not trigger rediscovery.
---
## 10. State Synchronization
NGN keeps a local mirror of contract-visible persistent state.
Synchronization strategy:
1. request initial `state.get`;
2. record `stateRevision`;
3. apply normalized state/selection events;
4. track event sequence;
5. resynchronize on uncertainty.
Resync triggers include:
- event sequence gap;
- state revision gap that cannot be explained by one received mutation transaction;
- reconnect/new session;
- explicit `registry.changed` requiring rediscovery;
- configurable synchronization timeout;
- parse/validation failure on a state event.
On resync, request fresh `state.get`.
---
## 11. Feedback-Loop Prevention
NGN respects authoritative event `source`.
Default recorder sources:
```text
ui
midi
hotkey
```
Optional:
```text
host
```
Normally excluded:
```text
scenario
internal
system
```
Scenario playback does not record itself by default.
Host-triggered integrations do not recursively retrigger identical integrations unless explicitly configured.
---
## 12. Scenario Model
A scenario is an NGN-owned orchestration document.
The ordinary exhibit does not need to understand the document.
A scenario may include:
- timed state changes;
- impulses;
- speech;
- waits;
- marks;
- tracks;
- ramps;
- holds;
- conditions;
- repetition;
- random variation;
- external signals;
- transitions;
- metadata;
- required capabilities;
- cleanup policy.
---
## 13. Scenario Format
Scenario schema is distinct from the XZBT Exhibit Contract.
It references canonical contract target IDs.
Example:
```json
{
"format": "xzbt-scenario",
"version": 1,
"requires": ["speech"],
"timeline": [
{
"at": 0,
"op": "set",
"target": "view.observation",
"value": true
},
{
"at": 2000,
"op": "invoke",
"target": "speech.say",
"args": {
"text": "Approaching docking perimeter."
}
}
]
}
```
Scenario data is inert data.
It MUST NOT contain arbitrary executable JavaScript, CSS, selectors, or host-language expressions.
The scenario format should eventually receive its own specification.
---
## 14. Scenario Validation
Before playback, validate:
- scenario schema;
- target existence;
- target kind;
- writable/invokable semantics;
- required capability state;
- value ranges;
- selection options;
- optional exhibit/product compatibility constraints.
Invalid operations should be surfaced before playback where possible.
---
## 15. Scenario Clock
NGN owns the live scenario clock.
Initial support:
- millisecond timing;
- pause/resume;
- marks;
- loops;
- holds.
Later support may include:
- variable speed;
- repeat regions;
- musical timing;
- advanced conditions.
Browser-hosted exhibit timing is application-level timing, not sample-accurate show control.
---
## 16. State Capture and Cleanup
Scenario playback may capture relevant initial state.
Cleanup policy may:
- leave final state;
- restore captured state;
- restore selected targets only.
Impulse targets are never restorable.
Persistent state targets are restorable by default unless the exhibit explicitly sets `restorable: false`.
---
## 17. Scenario Recorder
NGN records normalized exhibit events using canonical target IDs.
Default sources:
```text
ui
midi
hotkey
```
Optional:
```text
host
```
Normally excluded:
```text
scenario
internal
system
```
The recorder never stores internal SciFi-XZBT IDs.
---
## 18. Recorder Compression
Continuous controls may generate many events.
Later compression may include:
- threshold reduction;
- sample reduction;
- ramp fitting;
- minimum time interval.
This is deferred optimization, not MVP behavior.
---
## 19. Timeline Authoring
Future authoring may support:
- tracks;
- marks;
- ranges;
- impulses;
- speech;
- ramps;
- holds;
- loops;
- conditions;
- external triggers;
- annotations;
- metadata.
The first editor can be much simpler.
---
## 20. Webhook Engine
Webhooks terminate at NGN.
NGN handles:
- authentication;
- authorization;
- validation;
- rate limiting;
- routing;
- logging;
- translation.
Webhook actions may target:
- exhibit contract operations;
- NGN scenario controls;
- telemetry mappings;
- connector commands.
The standalone exhibit never needs an HTTP listener.
---
## 21. Connector Framework
NGN may later provide connectors for:
- Streamer.bot;
- OBS-related systems;
- REST;
- MQTT;
- serial devices;
- MIDI;
- simulators;
- game servers;
- home automation;
- future MCP integrations.
Connectors normalize external inputs into:
- exhibit contract operations;
- NGN scenario controls;
- NGN telemetry updates.
---
## 22. Telemetry Mapping
Structured telemetry is optional per exhibit.
NGN MUST discover whether telemetry targets actually exist.
It must not assume that SciFi-XZBT has fictional telemetry fields merely because the contract supports them.
When writable telemetry exists, NGN may provide:
- scaling;
- unit conversion;
- clamping;
- smoothing;
- update-rate control;
- stale-data policy;
- ownership lease renewal;
- fallback to simulation.
---
## 23. Telemetry Staleness
For external telemetry mappings, NGN SHOULD define stale-data behavior.
Options may include:
- keep last value;
- mark stale;
- release ownership;
- return to exhibit simulation;
- invoke fallback value.
The mapping configuration should make this explicit.
---
## 24. Speech Integration
NGN may send explicit text to an exhibit's `speech.say` target.
Potential sources include:
- scenario text;
- webhook payload;
- generated dialogue;
- simulator data;
- operator input.
NGN does not reproduce the exhibit's speech engine.
---
## 25. Scenario Packaging
NGN SHOULD eventually generate self-running packaged HTML experiences.
Concept:
```text
Base Exhibit
+
Scenario Data
+
Compact Scenario Runtime
+
Required Embedded Configuration
|
v
Packaged HTML
```
The packaged artifact may run without NGN.
---
## 26. Shared Scenario Runtime Core
Live and packaged playback MUST share one scenario-runtime core.
The runtime MUST drive the exhibit through the same canonical target surface used in live NGN operation.
It MUST NOT bypass contract semantics by calling arbitrary exhibit internals.
The runtime transport is pluggable:
```text
live NGN -> host transport / postMessage
packaged HTML -> direct in-process contract adapter
```
This keeps scenario semantics aligned while avoiding unnecessary host-bridge overhead inside a packaged artifact.
---
## 27. Compact Packaged Runtime
The exported runtime includes only execution functionality needed by the package.
It may include:
- scenario clock;
- set/invoke dispatch;
- waits;
- marks;
- loops;
- required conditions;
- cleanup logic.
It does not include:
- authoring UI;
- recorder;
- webhook server;
- connector framework;
- NGN administration;
- multi-exhibit management.
---
## 28. Packaged Exhibit Contract Access
A packaged exhibit retains the same canonical exhibit target surface.
The embedded runtime is a local consumer of that surface.
External host attachment may remain available if the base exhibit supports it, but is not required for packaged scenario execution.
---
## 29. Package Size Discipline
The compact scenario runtime should remain small relative to the exhibit.
Packaging SHOULD report:
- base exhibit size;
- runtime size;
- scenario-data size;
- final package size.
No hard numeric limit is imposed in v5.2, but the runtime SHOULD NOT approach the size of the base exhibit unless a future capability explicitly justifies it.
---
## 30. Administration
Future NGN administration may include:
- installed exhibits;
- running exhibits;
- session health;
- discovered capabilities;
- target catalog;
- current state;
- action testing;
- scenarios;
- webhook configuration;
- connectors;
- telemetry mappings;
- logs;
- packaging tools.
---
## 31. Security
Because NGN may expose local or network services, it owns the heavier trust boundary.
Eventually address:
- authentication;
- authorization;
- webhook tokens;
- origin restrictions;
- input validation;
- rate limiting;
- connector permissions;
- bind-address defaults;
- TLS guidance;
- secret storage;
- audit logging;
- least privilege.
The exhibit still validates every contract message.
---
## 32. Network Defaults
NGN SHOULD default to safe local binding.
Network exposure must be explicit.
A premium feature must not accidentally become an unauthenticated LAN or Internet control surface.
---
## 33. Logging and Diagnostics
NGN should maintain structured logs for:
- session connection;
- version negotiation;
- describe results;
- registry changes;
- state resync;
- scenario actions;
- webhook requests;
- connector errors;
- capability failures;
- packaging operations.
Logging should support diagnosis without requiring browser developer tools.
---
## 34. Version Compatibility
Track separately:
- NGN application version;
- XZBT contract version;
- exhibit product version;
- scenario format version;
- registry revision;
- state revision.
These are distinct concepts.
---
## 35. Implementation Phases
### Phase 0 - Contract harness
Build a small synthetic exhibit and host test harness for XZBT Contract 5.2.
### Phase 1 - Single exhibit host
Host SciFi-XZBT and establish a session.
### Phase 2 - Dynamic discovery UI
Render capabilities, targets, state, and events.
### Phase 3 - State synchronization
Implement revision and sequence tracking plus resync behavior.
### Phase 4 - Minimal scenario runtime
Support timed `set` / `invoke` operations and cleanup.
### Phase 5 - Recorder
Capture canonical operator events.
### Phase 6 - Scenario persistence and basic editing
Save/load scenario documents and provide basic editing.
### Phase 7 - Webhooks
Add authenticated external triggers.
### Phase 8 - Telemetry connector framework
Only after at least one exhibit exposes structured writable telemetry.
### Phase 9 - Integration adapters
Add Streamer.bot and other useful adapters.
### Phase 10 - Packaging
Export self-running HTML using the shared runtime core.
### Phase 11 - Advanced authoring
Add richer timeline features, ramps, conditions, musical timing, and repeat regions.
### Phase 12 - Multi-exhibit engine
Add orchestration across multiple sessions when justified.
---
## 36. MVP Acceptance Criteria
NGN MVP succeeds when:
- one SciFi-XZBT exhibit can be hosted or attached;
- contract handshake succeeds;
- `describe` renders dynamically;
- current state is displayed;
- representative state can be changed;
- representative impulses can be triggered;
- events are displayed with source and sequence;
- state revision changes are tracked;
- reconnect resets sequence expectations;
- lost synchronization triggers fresh `state.get`;
- registry changes trigger debounced rediscovery;
- no SciFi-specific target vocabulary is hard-coded into core NGN logic.
---
## 37. Scenario Milestone Acceptance Criteria
The first scenario milestone succeeds when:
- a simple scenario document loads;
- target references validate against `describe`;
- timed set/invoke operations execute;
- scenario-origin events do not recursively record themselves;
- initial-state restore works for restorable targets;
- abort cleanup is predictable;
- the same runtime core can be used by the packager.
---
## 38. Packaging Milestone Acceptance Criteria
Packaging succeeds when:
- a base exhibit and scenario combine into one distributable HTML;
- the package runs without NGN;
- the scenario runtime drives the canonical target surface;
- no authoring/admin/webhook stack is included;
- live and packaged runtime behavior matches for supported operations;
- package size is reported;
- the ordinary exhibit remains independently usable outside the package process.
---
## 39. Deferred Features
Intentionally deferred:
- musical timing;
- complex condition language;
- multi-exhibit synchronization;
- distributed receivers;
- cloud orchestration;
- advanced telemetry transforms;
- generic plugin marketplace;
- arbitrary scripting;
- sample-accurate show control.
---
## 40. Architectural Rule
Use the same test for every proposed feature.
**Does the exhibit need it to remain a complete standalone exhibit?**
Then it may belong in the exhibit.
**Does it coordinate, automate, connect, record, author, distribute, or externally control exhibits?**
Then it belongs primarily in XZBT-NGN.
---
## 41. Version 5.2 Summary
Version 5.2 preserves the Version 5 architecture and hardens the implementation boundary.
The first responsibility of NGN remains proving reliable discovery, observation, synchronization, and control.
Scenario authoring, webhooks, telemetry, integrations, and packaging all build on that verified contract surface.
@@ -0,0 +1,577 @@
# XZBT Exhibit Authoring Guide
## Version 5.2
**Status:** Authoring guidance (non-normative except where explicitly marked)
**Companion to:** XZBT Exhibit Contract Specification 5.2
**Audience:** developers and coding agents building a new XZBT-compatible exhibit
---
## A. Purpose and Audience
This guide explains how to structure a new, standalone exhibit so that it correctly implements the XZBT Exhibit Contract from the start, without absorbing responsibilities that belong to the XZBT-NGN Exhibit Engine.
It is written for two kinds of reader: a human developer building an exhibit by hand, and a coding agent implementing one from a specification. Both should be able to follow it without prior exposure to any specific existing exhibit.
Two documents work together and answer different questions:
- The **XZBT Exhibit Contract Specification** defines the external behavior a conforming exhibit MUST, SHOULD, or MAY exhibit at the message and semantics level: the handshake, the message envelope, target kinds, revisions, events, capabilities, and error codes.
- This **Authoring Guide** explains a recommended internal implementation structure that makes satisfying that contract straightforward, consistent, and maintainable, and calls out the mistakes that make it hard.
Where this guide uses MUST, MUST NOT, SHOULD, or SHOULD NOT for something that is not already a contract requirement, that is an authoring recommendation, not a new contract rule. Every such case is called out explicitly. Nothing in this guide expands, narrows, or reinterprets Contract 5.2.
An example exhibit, SciFi-XZBT, is referenced throughout for concreteness. Nothing here is specific to that exhibit's science-fiction subject matter, and none of its vocabulary should be treated as universal. A train exhibit, an aviation exhibit, a castle exhibit, and any future XZBT exhibit are equally valid, and this guide is written so that none of them inherit assumptions that only make sense for a starship bridge.
---
## B. Core Design Principle: Standalone First
**CONTRACT REQUIREMENT.** Contract 5.2 §2 states that an XZBT-compatible exhibit MUST remain operable without XZBT-NGN, and that connection to XZBT-NGN is additive.
This is the single governing constraint on everything else in this guide. In practice it means an exhibit:
- MUST run meaningfully with no host attached at all — full native UI, full native behavior, full native content.
- MUST NOT require a handshake, a session, or any host message to initialize, animate, play audio, or respond to local input.
- MUST NOT require network access, a local server, or any orchestration layer for ordinary operation.
- MUST preserve its native UI's own behavior and feel regardless of whether a host is attached.
- MUST remain a complete, self-contained artifact a person can open and use with nothing else running.
**AUTHORING RECOMMENDATION.** The contract layer should be dormant, not merely tolerant, when no host is present. Concretely: the exhibit boots, and only when (and if) a host later sends `hello` does any session-related code path activate. A useful test during development is to delete every host-detection code path and confirm the double-clicked file behaves identically. If a future host connects, its presence should feel like an enhancement layered on top of a complete experience, never like flipping the exhibit into a different mode of existence.
---
## C. Recommended Internal Architecture
**AUTHORING RECOMMENDATION.** No specific framework, build system, or language is required. Plain HTML, CSS, and JavaScript with no framework at all remains completely valid, and a conforming exhibit may equally be built with a modern framework, a game engine's UI layer, or a native application shell rendering through the same message-passing conventions where applicable. What matters is the separation of concerns below, not the tooling used to express it.
A recommended internal separation, roughly in dependency order (each layer may call the ones above it, and generally should not be called by them):
1. **Exhibit state/model.** The actual data that constitutes the exhibit's condition: is the transport running, what is the master volume, which scene or theme is selected, what alert state is active. This is the source of truth `state.get` eventually reads from.
2. **Exhibit services/behavior.** The logic that changes the model in response to something happening: starting playback, applying a scene, running the simulation loop, synthesizing a sound. This layer owns the actual mutation of state 1 and should not care who or what asked for the mutation.
3. **Native UI.** Buttons, sliders, dropdowns, canvases, whatever the exhibit's own screen presents. This layer calls into layer 2 and renders the current state from layer 1. It has no privileged access to contract internals and no special powers the contract adapter lacks.
4. **Canonical control layer.** One set of functions — the mutation/invoke chokepoint described in section H — that every input source (UI, hotkeys, host) calls to change state or trigger an action. This is the layer that makes source attribution, transaction semantics, and idempotency actually enforceable, because everything funnels through it.
5. **Contract adapter.** The layer that knows about XZBT vocabulary: canonical target IDs, descriptors, `describe`, `state.get`, event formatting, capability reporting. It translates between the canonical control layer's internal calls and the wire-level contract semantics. It does not itself contain exhibit behavior.
6. **Optional host transport.** The thin layer that moves contract messages across whatever channel is in use (same-origin `postMessage`, a wrapper binding, a local IPC channel). It has no knowledge of exhibit semantics; it only frames, validates origin, and forwards.
The dependency direction that matters most: layers 1–3 must work with layers 4–6 entirely absent. Layer 6 must be swappable without touching layers 1–4. A helpful gut check while designing any new piece of functionality is to ask which of these six things it is, and to resist the temptation to let a contract concern (layer 5–6) leak down into exhibit behavior (layer 1–3), or an exhibit behavior concern leak up into the adapter.
---
## D. Canonical Target Registry
**CONTRACT REQUIREMENT.** Target IDs use a canonical dotted namespace (Contract §8): lowercase ASCII letters, digits, hyphens and dots, beginning with a lowercase letter, at least one dot-separated segment boundary, no whitespace, no empty segments, and never interpreted as a JavaScript property path.
**AUTHORING RECOMMENDATION.** Design the registry as one stable, deliberately curated public catalog: canonical dotted IDs, each with a declared kind (state, range, selection, or impulse), current value where applicable, and a clear category. This catalog is what a generic host discovers through `describe`; it should describe the exhibit's meaning, not its markup.
Target IDs should name what a thing *means* to an operator, not where it lives in the DOM or how it happens to be wired internally.
Good examples:
```text
transport.playing
environment.intensity
mode.selected
event.pulse
```
Bad examples:
```text
button-14
slider-left
panelB.knob2
```
The difference is not cosmetic. A meaningful ID survives a UI redesign, a relayout, or a rewrite of the exhibit's internals, because it describes a concept the exhibit owns rather than a widget the exhibit happens to render today. `button-14` tells a host nothing about what will happen if it is invoked and becomes meaningless the day the button moves.
**AUTHORING RECOMMENDATION.** Adopt a fixed catalog rather than a catalog that changes shape as the exhibit's internal context changes. A target that only makes sense in one mode, theme, or scene should still generally remain in the catalog and simply report `CAPABILITY_UNAVAILABLE` (or an equivalent contextual error) when invoked outside that context, rather than disappearing and reappearing. Registry churn driven by ordinary context changes forces every attached host to rediscover the whole catalog constantly, which is expensive and provides no real benefit — the fixed-catalog approach lets a host build its control surface once and treat temporary unavailability as a normal, expected state rather than a structural surprise. See Section N for the discoverable-versus-invokable distinction this depends on.
---
## E. Target Descriptor Design
**CONTRACT REQUIREMENT.** Each target returned by `describe` must include enough metadata for a generic host to inspect and operate it (Contract §9): `id`, `kind`, `readable`, `writable`, `restorable`, `category`, `requires`, and kind-specific fields — `min`/`max`/`step` for range, `valueType` for state, `options` for selection. Where a target accepts structured arguments, those arguments must be validated against a declared schema (Contract §11).
**CONTRACT REQUIREMENT.** Descriptors must reflect real behavior. The contract must not advertise fake actions, unsupported values, or nonexistent capabilities. A published target that can never successfully execute in any context, for reasons no declared capability explains, is a describe-accuracy defect, not a permissible "discoverable but unavailable" case — that allowance exists specifically for context-gated unavailability tied to a real, declared capability (Contract §9, §17).
**AUTHORING RECOMMENDATION — the lesson from Step 3.** During SciFi-XZBT's Step 3 verification, several range descriptors advertised a wider span than the exhibit's own native slider could select, and in one case a native slider could select a value the descriptor explicitly forbade. The forbidden-but-selectable case is the dangerous direction: it lets a normal, local, unremarkable action (dragging a slider) produce a value the exhibit has told every host is impossible, which the canonical mutation path then has to silently reject — the thumb moves, nothing else does, and the operator gets no explanation.
The general rule this produced: **the native UI and the public contract range for the same underlying value should represent one coherent operator-facing range.** If the exhibit's own control can only usefully reach a narrower or coarser range than the descriptor claims, that is usually fine and often desirable — the descriptor can legitimately describe more of the backing capability than any one local widget exposes. If the native control can reach *further* or *finer* than the descriptor claims, that is a real defect regardless of how it happened, because a local, ordinary interaction is now able to produce a contract-invalid state. Author descriptors and native controls together, and treat "control broader than contract" as a bug class to check for explicitly during development, not just at final review.
---
## F. State Design
**CONTRACT REQUIREMENT.** Persistent state belongs in `state.get`; impulses do not appear in a restorable snapshot (Contract §10, §13). A state snapshot must not imply that every internal exhibit variable is externally exposed — only readable persistent targets belong in `values`.
**AUTHORING RECOMMENDATION.** Snapshots should contain stable, externally meaningful state: the things an operator or an authored scenario would reasonably want to read back or restore. Do not build a parallel shadow model purely to satisfy the contract's shape. If the exhibit's real internal state cannot yet answer a question the contract needs answered (for example, "is Observation mode currently on"), add a real readable getter to the exhibit's own model rather than fabricating a separate value the adapter tracks independently. A contract adapter that keeps its own belief about exhibit state, disconnected from the exhibit's actual state, will eventually disagree with it.
**CONTRACT REQUIREMENT.** No-op writes must not produce a new `stateRevision` (Contract §12, §14.3).
**CONTRACT REQUIREMENT — transaction semantics.** One top-level contract-visible mutation is one mutation transaction. All contract-visible state changes committed by that transaction share one resulting `stateRevision`; the exhibit computes and commits the transaction, increments `stateRevision` exactly once if anything externally visible changed, and only then emits the resulting `state.changed`/`selection.changed` events carrying that revision (Contract §14).
**AUTHORING RECOMMENDATION.** When a single operation changes several values coherently — selecting a scene that also selects that scene's default preset is the canonical example — commit all of them together, increment the revision once, and emit every changed target's event carrying that same shared revision. Treat "how many logically-connected values changed as one gesture" as the boundary of a transaction, not "how many individual setter functions happened to run."
---
## G. Absolute Setters and Idempotency
**AUTHORING RECOMMENDATION**, reflecting how Contract 5.2's state model is meant to be exercised (Contract §12 defines `set` as changing writable persistent state to a given value, not toggling it): public setters for persistent state should be absolute and idempotent. Setting a value to its current value twice should leave the exhibit in exactly the same state, with no second state change and no second revision.
Good:
```text
set mute = true
set mode = "night"
set intensity = 0.5
```
Bad:
```text
toggle mute
click mute button
move slider +10%
```
An exhibit's native UI is free to use toggle buttons or relative gestures internally — that is a legitimate presentation choice, and native UI is exhibit-owned. But the canonical contract path for the same underlying state must convert any such relative or toggle-shaped local action into an absolute value before it reaches the canonical mutation service. A host, a scenario, or another integration needs to be able to say "make this true" without knowing or caring what the current state was, and a relative-only setter makes that impossible to do reliably.
---
## H. Canonical Mutation / Invoke Path
**AUTHORING RECOMMENDATION**, and the single most load-bearing pattern in this guide: route every externally visible state change and every externally visible action through one canonical internal path.
Host control, native UI, hotkeys, and any other local control surface should converge on the same underlying services wherever practically possible. Concretely, something shaped like:
```text
applyMutation(targetId, value, source, context)
invokeAction(targetId, args, source, context)
```
should be the only way contract-visible state changes or actions actually happen, regardless of what triggered them.
**AUTHORING RECOMMENDATION — avoid DOM-click proxying.** Do not use synthetic DOM events (dispatching a fake `click`, `input`, or `change` event to make something happen) as the canonical mechanism for state mutation. This pattern shows up naturally when a hotkey handler is implemented as "find the button that does this and click it programmatically" — it is easy to write and easy to get wrong. It breaks source attribution (the event now looks identical to a real user click, because the code path *is* the click handler), it can double-apply if both the synthetic dispatch and a canonical call happen, and it makes the actual mutation logic impossible to test or reason about independently of the DOM. This was, concretely, the root cause of a real hotkey source-attribution defect in SciFi-XZBT: two keyboard shortcuts synthesized a button click to trigger their action, which meant the resulting event reported `source=ui` no matter how the action was actually triggered. The fix was to extract the shared logic into one function that both the button's own listener and the hotkey handler call directly, each passing its own true source.
Direct business/state methods — not DOM proxies — should be authoritative. The DOM is a rendering of state, not a control bus.
---
## I. Native UI Integration
**AUTHORING RECOMMENDATION.**
- The UI must remain fully functional with no host attached — this is a restatement of Section B at the UI layer specifically.
- UI code should call the canonical services described in Section H, not reimplement mutation logic inline in an event handler.
- Changes originating from the UI should emit the same normalized events a host-originated change would, with `source=ui`.
- UI state and contract state must stay synchronized: if a host sets a value, the UI must faithfully reflect it, and if the UI changes a value, the contract's readable state must reflect that immediately.
- Host-set values must render in the native UI exactly as they would if a local operator had set them — the UI must not have a way to silently distinguish or discard a host-originated value it doesn't like the look of. If a native control cannot exactly represent a value (see the range lesson in Section E), it must not simply drop or revert that value on the next unrelated interaction.
**CONTRACT REQUIREMENT.** Not every public target needs a visible UI control (Contract §19: sound and visual actions "should be exposed as impulse targets where useful," which does not imply UI parity). A target may legitimately be external-only when it maps to real, intentional exhibit behavior that simply has no dedicated on-screen control — for example, an event that exists as a synthesis function the exhibit already performs in another context, exposed to hosts without inventing a redundant button for it. What is not acceptable is a target whose only path to actually happening is a UI element that does not exist; the target must resolve to a real, verified, callable action regardless of whether a button triggers it too.
---
## J. Source Attribution
**CONTRACT REQUIREMENT.** Normalized events must identify the authoritative source of the action when known (Contract §15). The base source values are `ui`, `midi`, `hotkey`, `host`, `scenario`, `internal`, and `system`. A `source` value supplied inside a host command must be ignored; the receiving bridge assigns the authoritative source, never the sender.
**AUTHORING RECOMMENDATION.** Assign source at the trusted boundary that actually knows the truth, never by trusting a caller's claim about itself. The button's own click listener knows it is `ui`. The keydown handler knows it is `hotkey`. The host transport, after validating a message came through the negotiated session, knows it is `host`. None of these should accept an override from the thing they're receiving input from.
Do not teach host-provided source passthrough as authoritative: a host cannot spoof local provenance, by design, because source is assigned by the exhibit's own receiving path, not read out of the incoming message.
For future external control routed through XZBT-NGN — MIDI is the concrete example, following its removal from the standalone exhibit in favor of NGN-owned integration — the exhibit sees these operations arrive as ordinary contract requests over its host transport, and correctly reports `source=host`, because from the exhibit's point of view that is exactly what they are. NGN is free to separately retain finer-grained provenance (which connector, which physical controller) on its own side of the boundary; that detail is an NGN concern and never needs to cross into the exhibit's own event stream as a new base source value.
---
## K. Events
**CONTRACT REQUIREMENT.** A conforming exhibit must publish normalized events for contract-visible operations. The base event types are:
```text
state.changed
action.executed
selection.changed
capability.changed
registry.changed
error
```
**CONTRACT REQUIREMENT — sequencing.** Event `sequence` must increase monotonically within one session and is session-scoped: it resets when a new `sessionId` is issued, and a host must reset its own expected sequence after a reconnect (Contract §16.1). `stateRevision` tracks committed persistent-state history and does not reset with a new session; `sequence` tracks delivery order of every emitted event, including impulses and errors. The two counters serve different purposes and must not be treated as interchangeable (Contract §14.4).
**CONTRACT REQUIREMENT.** Impulses may emit `action.executed` without changing `stateRevision`, because an impulse by definition is not persistent state (Contract §10.4, §14).
**CONTRACT REQUIREMENT — correlation.** When an event is caused directly by a host request, it should carry that request's `requestId` as `correlationId` (Contract §16.2).
**AUTHORING RECOMMENDATION.** Do not introduce new canonical event types beyond the base six without very strong justification, and never as a routine part of building a new exhibit — the base set is deliberately exhibit-generic and sufficient for state, selection, action, capability, registry, and error reporting across very different subject matter.
---
## L. Session and Transport Separation
**CONTRACT REQUIREMENT.** The contract defines semantics and normalized message envelopes, not one mandatory transport. Permitted transports include same-origin `postMessage`, trusted wrapper bindings, a local WebSocket transport, local application IPC, and future host-specific bindings (Contract §4). The exhibit must not need to know whether a request originated from a webhook, an automation tool, a simulator, a telemetry source, an administrative UI, a scenario engine, or any other integration — NGN translates all of these into contract operations before they ever reach the exhibit.
**AUTHORING RECOMMENDATION.** Keep session semantics — handshake, `sessionId` issuance, sequence tracking — cleanly separated from exhibit logic, so that swapping the transport later (say, from `postMessage` to a packaged in-process adapter, per the NGN packaging model) requires changing only the transport layer described in Section C, not the contract adapter or the exhibit itself. Do not let `postMessage`-specific assumptions (message event shape, origin checks) leak into the contract adapter's own logic; keep them isolated in the transport layer that happens to implement that one binding.
**CONTRACT REQUIREMENT — same-origin security.** Where `postMessage` is used, both `event.origin` and `event.source` must be validated against the expected host relationship on every message (Contract §25).
---
## M. Capabilities
**CONTRACT REQUIREMENT.** Capabilities are discoverable and stateful. The base lifecycle states are `unsupported`, `available`, `loading`, `ready`, `busy`, and `error` (Contract §17). A capability may change state during a session, and any such change must emit `capability.changed`. A host must treat the current discovered state as authoritative.
**AUTHORING RECOMMENDATION.** Do not advertise a capability that does not exist, and do not invent lifecycle transitions the exhibit cannot honestly report. Speech is a useful worked example: `available` should mean the exhibit implements speech at all; `loading` should reflect real initialization in progress (voice model loading, engine startup); `ready` should mean speech can actually be used right now; `error` should reflect a genuine initialization or operation failure. `busy` is optional — it describes ongoing occupancy (mid-playback, for instance) and is not required by the contract; wiring it costs real engineering effort (start/end hooks, interruption handling) for a state most hosts do not strictly need. It is entirely acceptable to wire `available → loading → ready/error` for a capability and leave `busy` unimplemented; what is not acceptable is a capability that is hard-coded to a static state that is never actually verified against real subsystem behavior. Not every capability needs to exercise every lifecycle state — use only the states that reflect something real for that capability.
---
## N. Contextual Availability
**CONTRACT REQUIREMENT.** When a required capability is not usable, a target remains discoverable but must not be operated successfully; a generic host should present it as unavailable rather than removing it (Contract §9). `registryRevision` should not change merely because a fixed target becomes contextually unavailable (Contract §23).
**AUTHORING RECOMMENDATION.** Keep discoverable and currently invokable as two distinct questions. A sound effect can remain permanently in the fixed catalog while returning `CAPABILITY_UNAVAILABLE` in the wrong mode or theme — that is normal, expected, and cheap for a host to handle. What must not happen is the registry itself churning (targets appearing and disappearing, `registryRevision` incrementing) merely because the exhibit switched modes; that is exactly the unnecessary rediscovery cost Section D warns against. Reserve `registryRevision` increments for cases where the target set or its descriptor metadata genuinely changed — not for ordinary state transitions the exhibit goes through as part of normal operation.
---
## O. Safe Text Handling
**CONTRACT REQUIREMENT.** Any text surface the exhibit exposes must treat text strictly as data (Contract §20). The contract must not permit arbitrary HTML, JavaScript, CSS, selectors, or executable expressions through a text target. Each text target should declare a maximum accepted length.
**AUTHORING RECOMMENDATION.** Use explicit, narrowly-typed text arguments in a target's schema rather than an open-ended payload object. Reject unknown argument keys (Contract §11: "unexpected argument keys should be rejected"). Render accepted text using safe mechanisms — `textContent` assignment or an equivalent that cannot be interpreted as markup — never `innerHTML` or an equivalent that would let a string become executable content. This applies equally to text arriving through a live contract message and text arriving embedded in scenario data (Section R): scenario data must remain inert, and no contract or scenario argument should ever be capable of causing arbitrary script execution inside the exhibit.
---
## P. Optional Telemetry
**CONTRACT REQUIREMENT.** Telemetry support is entirely optional; the contract defines how telemetry is represented if an exhibit exposes it, without requiring any exhibit to implement it (Contract §21).
**AUTHORING RECOMMENDATION.** Do not invent telemetry fields merely to look complete against the contract. A telemetry target should exist only when the exhibit actually has a meaningful structured value behind it — a real number, from a real subsystem, that changes in a way worth exposing. Manufacturing a `telemetry.*` target with no real backing state produces exactly the kind of describe-accuracy problem Section E warns against for ordinary targets. If the exhibit's current telemetry-like presentation is really just audio activity levels or scrolling text rather than a genuine structured numeric model, building real structured telemetry is a new feature to design deliberately, not a checkbox to tick during initial contract compliance.
Acquisition, mapping, unit conversion, smoothing, and staleness policy for telemetry sourced from something external to the exhibit are XZBT-NGN's responsibility, not the exhibit's (Contract §22, NGN Plan §22–23). The exhibit's job is to declare what it can meaningfully receive or report and to behave sensibly according to its own declared contract when values arrive; it does not need to know or care where a telemetry value ultimately originated.
---
## Q. External Integrations
**CONTRACT REQUIREMENT.** The exhibit exposes what it can do; the contract defines how that is described, observed, and invoked; XZBT-NGN decides how to orchestrate and integrate it (Contract §30). NGN, not the exhibit, owns discovery, coordination, automation, external connectors, recording, and administration (Contract §3.2, NGN Plan §3, §40).
**AUTHORING RECOMMENDATION.** A standalone exhibit generally should not own MIDI, webhooks, external automation buses, game integrations, or other connector-specific logic. The architectural test to apply to any proposed piece of functionality, borrowed directly from the settled NGN/exhibit boundary: if it coordinates, automates, connects, authors, records, distributes, or externally controls the exhibit, it belongs primarily in XZBT-NGN. If it is required for the exhibit to remain a complete standalone experience, it belongs in the exhibit.
This is not an absolute prohibition — an integration that is truly intrinsic to what makes a specific exhibit a complete standalone artifact could still belong in the exhibit itself. But that should be a deliberate, justified exception, argued explicitly against the test above, not a default. When in doubt, expose a clean primitive on the exhibit side (a target that does the underlying thing) and let external integration logic live entirely in NGN, translating whatever external signal into a normal contract operation against that primitive. This is precisely the shape of the resolution used when MIDI-specific code was separated from the generic control-bus abstraction it had been bundled with: the generic bus mechanism is a legitimate exhibit-side primitive; the MIDI-specific binding and mapping logic is not.
---
## R. Scenarios
**CONTRACT REQUIREMENT.** The XZBT Exhibit Contract does not require a general-purpose scenario engine in the standalone exhibit; a scenario is an orchestration concept, and XZBT-NGN executes scenarios by issuing ordinary contract operations over time (Contract §26). Scenario data must remain inert and must never be interpreted as arbitrary executable code.
**AUTHORING RECOMMENDATION.** General scenario authoring, recording, and playback belong to XZBT-NGN, not the exhibit. What the exhibit should provide is a set of clean, controllable primitives — well-designed targets — that a scenario can drive; it does not need to know a scenario is happening at all, and from the exhibit's point of view a scenario-driven operation is just a `set` or `invoke` arriving through its host transport like any other.
A separately packaged exhibit build, produced by NGN for self-running distribution, may later include a minimal runtime that drives the exhibit's own canonical target surface from embedded scenario data (NGN Plan §26–27). That compact runtime is still a consumer of the same contract surface the exhibit already exposes — it is not a reason to build a general scenario editor or player into every ordinary exhibit. Do not embed scenario authoring or playback tooling in the exhibit as a default; if a packaged build needs it, that tooling is added at packaging time by NGN's shared runtime core, not maintained as a permanent part of the standalone artifact.
---
## S. Packaging and Offline Operation
**AUTHORING RECOMMENDATION**, following directly from Section B: a standalone exhibit intended to run as a single distributable artifact should remain fully self-contained wherever that is the intended distribution shape — no server required for normal standalone use, no external script or stylesheet dependency that would break when opened offline or from a local file. Any optional network-dependent enhancement (checking for updates, an optional cloud feature) should fail gracefully and silently fall back to fully local behavior rather than degrading the core experience or throwing a visible error.
Packaging should preserve contract behavior: whatever process turns exhibit source into a distributable artifact should not need special-case handling to keep the contract adapter, canonical mutation path, or event surface intact — if packaging strips or reorders code in a way that breaks contract compliance, that is a packaging defect, not an acceptable tradeoff.
**Do not treat SciFi-XZBT's own specific offline/packaging policy (its particular single-file HTML packaging approach, or any Web3D-related constraint it happens to carry) as a universal requirement.** A different exhibit might reasonably ship as a small set of files, or run inside a different host shell entirely. The universal principle is graceful, complete standalone operation; the specific packaging mechanics are exhibit-owned implementation choices.
---
## T. Error Handling
**CONTRACT REQUIREMENT.** Implementations should use stable, machine-readable error codes rather than relying on message text (Contract §24). The recommended base codes are:
```text
UNSUPPORTED_VERSION
INVALID_MESSAGE
INVALID_SESSION
UNKNOWN_TARGET
INVALID_VALUE
INVALID_ARGUMENTS
CAPABILITY_UNAVAILABLE
TARGET_READ_ONLY
TARGET_NOT_INVOKABLE
TARGET_NOT_SETTABLE
INTERNAL_ERROR
```
Human-readable error text is advisory only; hosts should branch on codes, never on message strings.
**AUTHORING RECOMMENDATION.**
- Reject invalid input cleanly and immediately, with a specific code, rather than accepting it and behaving unpredictably.
- Never apply a partial mutation — if any part of a validated transaction cannot be committed, commit none of it.
- Do not silently clamp an out-of-range value to the nearest valid one unless the target's own descriptor explicitly documents clamping as its defined behavior; silent clamping without a declared contract for it produces exactly the kind of state/host disagreement Section E warns about.
- Never report success when the requested state did not actually change as requested. A response of `ok: true` is a claim that the operation was accepted for execution (Contract §11) — it must correspond to something real actually happening, not to the request merely being well-formed.
---
## U. Testing Strategy
**AUTHORING RECOMMENDATION.** A new exhibit's XZBT compliance should be exercised at several distinct levels, each catching a different class of defect:
1. **Synthetic/unit tests.** Exercise the canonical mutation/invoke path and the contract adapter directly, without a browser or a real host — target validation, no-op detection, revision increment logic, event shape.
2. **Contract adapter tests.** Verify `describe`, `state.get`, capability reporting, and error codes conform to the wire-level contract shape, independent of any specific transport.
3. **Real browser host tests.** Attach an actual host (or a minimal test harness acting as one) over the real transport and verify handshake, discovery, state read/write, invoke, and event delivery end to end.
4. **Native UI convergence tests.** Confirm that UI-originated changes and host-originated changes both flow through the same canonical path and produce equivalent, correctly-sourced events — this is where a lingering DOM-click proxy (Section H) or a UI/contract range mismatch (Section E) tends to surface.
5. **Standalone no-host tests.** Load the exhibit with no host attached at all and confirm every feature works exactly as it would with one — this is the direct verification of Section B's governing principle.
6. **Packaging tests.** Confirm the packaged/distributable artifact behaves identically to the development build with respect to contract compliance.
7. **Local-file/offline tests, where applicable.** For an exhibit intended to run as a double-clicked local file or fully offline, confirm it actually does — including any browser security constraints on `file://` access that a development environment's own tooling may not replicate.
A minimal conformance checklist for quick reference during development is provided in Appendix A.
---
## V. Reference Exhibit Expectations
**AUTHORING RECOMMENDATION**, describing what a later reference-exhibit pass (not undertaken here) should aim to demonstrate. Reference exhibits such as a train exposition or an aviation exposition should be:
- small — deliberately minimal in scope, not a showcase of every contract feature at once;
- intentionally simple — easy to read end to end as a worked example;
- fully standalone, per Section B;
- visibly different in subject domain from any existing exhibit, to prove the contract generalizes rather than merely repeating one domain's shape;
- contract-correct against every applicable MUST in the specification;
- free of any dependency on science-fiction-specific vocabulary or assumptions;
- useful primarily as an example implementation for future exhibit authors to read.
The point of a reference exhibit is to prove genericity, not to demonstrate feature richness. A train exhibit exposing `engine.throttle`, `brake.pressure`, `event.whistle`, and `telemetry.speed` (the exact example given in Contract §27) makes the architectural point far better than a large, feature-complete build would.
---
## W. Anti-Patterns
The following are recurring mistakes to avoid when building a new exhibit, several of them drawn directly from real defects found during SciFi-XZBT's contract verification:
- Using DOM element IDs, CSS selectors, or internal widget names as public contract target IDs.
- Implementing a persistent, toggle-shaped piece of state as a toggle-only contract action instead of an absolute setter.
- Maintaining a separate "shadow" adapter state that can drift from the exhibit's real internal state.
- Using synthetic DOM clicks or dispatched events as the canonical control-mutation mechanism.
- Trusting a host-supplied `source` value instead of assigning source at the trusted receiving boundary.
- Publishing a target in `describe` that has no real, verified, callable backing action in any context.
- Changing the registry, or incrementing `registryRevision`, on every ordinary mode or context switch.
- Embedding external integrations (MIDI, webhooks, third-party connectors) directly into the standalone exhibit by default.
- Making any part of ordinary standalone operation depend on XZBT-NGN being present.
- Putting arbitrary executable code, HTML, or expressions into scenario data or contract text arguments.
- Adding telemetry fields that have no real backing state, purely to look complete against the contract.
- Letting a native UI control accept or produce values outside the range its own public descriptor advertises.
- Silently reverting a committed host- or scenario-set value the next time a local control is touched.
---
## X. Recommended New-Exhibit Build Order
**AUTHORING RECOMMENDATION.** A practical sequence for building a new XZBT-compatible exhibit from scratch:
1. Build the exhibit's own standalone behavior first — visuals, audio, simulation, native UI — with no contract awareness at all.
2. Define stable internal state and services (layers 1–2 of Section C) that the exhibit's own UI already uses.
3. Define the canonical public target catalog (Section D) against that real internal model.
4. Implement absolute setters and idempotent actions (Section G) backing each target.
5. Route the native UI through the canonical services (Section H) so UI-originated and future host-originated changes share one path.
6. Add the contract adapter: descriptors, target metadata, capability model (Sections E, M).
7. Add discovery, state, and event surfaces: `describe`, `state.get`, `state.changed`/`action.executed`/etc. (Sections F, K).
8. Add the transport layer (Section L), keeping it isolated from exhibit and adapter logic.
9. Add capability reporting wired to real subsystem lifecycle, not static claims (Section M).
10. Run the conformance tests in Section U, including the checklist in Appendix A.
11. Test standalone and offline operation explicitly, with no host present at all.
12. Only after all of the above is solid, integrate with a real XZBT-NGN instance.
Building in this order keeps the exhibit correct and complete on its own at every step, and treats contract compliance as a layer added on top of a working exhibit rather than a scaffold the exhibit is built inside of.
---
## Y. Minimal Example Architecture
The following are short, conceptual, JavaScript-like pseudocode fragments illustrating the patterns above. They are not a working application and deliberately omit error handling detail already covered in Section T.
**Target registry (Section D, E):**
```js
const TARGET_REGISTRY = {
'transport.playing': { kind: 'state', valueType: 'boolean', readable: true, writable: true, restorable: true, category: 'transport', requires: [] },
'mix.master': { kind: 'range', min: 0, max: 1, step: 0.01, readable: true, writable: true, restorable: true, category: 'mix', requires: ['audio'] },
'mode.selected': { kind: 'selection', options: [{ value: 'day', label: 'Day' }, { value: 'night', label: 'Night' }], readable: true, writable: true, restorable: true, category: 'mode', requires: [] },
'event.pulse': { kind: 'impulse', readable: false, writable: false, restorable: false, category: 'event', requires: [] },
};
```
**Absolute setter (Section G):**
```js
function setMuted(on) {
if (exhibitState.muted === on) return { changed: false };
exhibitState.muted = on;
audioEngine.setMuted(on);
return { changed: true };
}
```
**Canonical mutation transaction (Section F, H):**
```js
function applyMutation(targetId, value, source) {
const before = snapshotRelevantState(targetId);
const result = dispatchToSetter(targetId, value); // calls setMuted(), etc. directly
if (!result.changed) return { ok: true, revisionChanged: false };
const changedTargets = diffState(before, snapshotRelevantState(targetId));
stateRevision += 1;
for (const t of changedTargets) {
emitEvent('state.changed', { target: t.id, value: t.value, stateRevision, source });
}
return { ok: true, revisionChanged: true };
}
```
**Impulse action (Section G, K):**
```js
function invokeAction(targetId, args, source) {
const descriptor = TARGET_REGISTRY[targetId];
if (!descriptor || descriptor.kind !== 'impulse') return { ok: false, code: 'TARGET_NOT_INVOKABLE' };
if (!capabilityReady(descriptor.requires)) return { ok: false, code: 'CAPABILITY_UNAVAILABLE' };
performRealAction(targetId, args); // no synthetic DOM dispatch
emitEvent('action.executed', { target: targetId, args, source });
return { ok: true };
}
```
**State snapshot (Section F):**
```js
function getStateSnapshot() {
const values = {};
for (const [id, d] of Object.entries(TARGET_REGISTRY)) {
if (d.readable && d.kind !== 'impulse') values[id] = readCurrentValue(id);
}
return { stateRevision, values };
}
```
**Event emission (Section K):**
```js
let sequence = 0;
function emitEvent(type, payload) {
sequence += 1;
hostTransport.send({ xzbt: '5.2', type, sessionId: currentSessionId, sequence, timestamp: Date.now(), ...payload });
}
```
**Host transport adapter (Section C, L):**
```js
window.addEventListener('message', (event) => {
if (event.source !== window.parent || !isTrustedOrigin(event.origin)) return;
const msg = event.data;
if (!isValidEnvelope(msg)) return respondError(msg, 'INVALID_MESSAGE');
if (msg.type !== 'hello' && msg.sessionId !== currentSessionId) return respondError(msg, 'INVALID_SESSION');
switch (msg.type) {
case 'hello': return handleHello(msg);
case 'describe': return respondDescribe(msg);
case 'state.get': return respondState(msg);
case 'set': return respond(msg, applyMutation(msg.target, msg.value, 'host'));
case 'invoke': return respond(msg, invokeAction(msg.target, msg.args, 'host'));
}
});
```
---
## Z. Final Author Checklist
Before calling a new exhibit XZBT-compatible, confirm:
- It runs completely, with full native behavior, with no host attached.
- Every public target ID is meaningful, lowercase-dotted, and free of DOM/widget-specific naming.
- Every descriptor reflects a real, verified backing action or state — nothing fake, nothing aspirational.
- Persistent setters are absolute and idempotent; setting the same value twice produces one state, zero extra revisions.
- All contract-visible mutation and invocation, from every input source, passes through one canonical path.
- No synthetic DOM dispatch is used as the mutation mechanism.
- Source is assigned only at the trusted receiving boundary, never trusted from the caller.
- One mutation transaction produces at most one `stateRevision` increment, and no-ops produce none.
- Native UI and public contract ranges represent one coherent operator-facing range.
- Capability states reflect real subsystem lifecycle, using only the states that are genuinely meaningful for that capability.
- The registry is fixed; context changes produce contextual errors, not registry churn.
- All text targets are handled as safe data, with declared length limits and no markup/script injection path.
- Telemetry, if present at all, is backed by real structured values — never fabricated for completeness.
- MIDI, webhooks, and other external integrations are not embedded in the exhibit by default.
- No general scenario authoring/recording/playback system has been built into the exhibit.
- Errors use stable machine-readable codes; no partial mutations; no silent clamping without a declared contract for it.
- The exhibit has been tested standalone, offline (where applicable), and through a real host, per Section U.
---
## Appendix A: Minimal Conformance Checklist
- [ ] Handshake: exhibit responds to `hello` with a session, or `UNSUPPORTED_VERSION` when incompatible.
- [ ] `describe` returns identity, contract version, `registryRevision`, `stateRevision`, capabilities, and targets.
- [ ] At least one discoverable target of each kind actually in use (state/range/selection/impulse).
- [ ] `state.get` returns only readable persistent targets, matching `describe`.
- [ ] `set` on a persistent target is idempotent; a no-op set does not change `stateRevision`.
- [ ] `invoke` on an impulse target executes a real action and emits `action.executed`.
- [ ] One transaction, one revision: a multi-value change increments `stateRevision` exactly once.
- [ ] Events carry monotonic `sequence`, reset on new session; `stateRevision` does not reset with session.
- [ ] `source` is accurate for `ui`, `hotkey`, and `host` origins in real, physically-triggered tests.
- [ ] Capability states reflect real initialization/readiness, not static placeholders.
- [ ] Unknown target, invalid value, invalid session, and invalid arguments each return the correct distinct error code.
- [ ] `postMessage` transport (if used) validates both `event.origin` and `event.source`.
- [ ] The exhibit runs fully standalone with no host, network, or server present.
- [ ] The packaged/distributable build behaves identically to the development build for all of the above.
## Appendix B: Recommended Project Structure
This is an illustrative layout, not a required one — see Section C for the underlying separation of concerns it expresses.
```text
/exhibit-root
index.html # entry point; loads with no host required
/js
model.js # exhibit state (layer 1)
services.js # exhibit behavior / mutation logic (layer 2)
ui.js # native UI, calls canonical services (layer 3)
canonical-control.js # applyMutation / invokeAction chokepoint (layer 4)
contract-adapter.js # describe/state.get/events/capabilities (layer 5)
host-transport.js # postMessage or other binding (layer 6)
/css
style.css
/assets
...
```
A single-file packaged build may inline all of the above; the separation should still exist logically within the source even when the distributable artifact is one file.
## Appendix C: Glossary
**XZBT Exhibit Contract** — the versioned specification defining the message envelope, target model, revisions, events, capabilities, and error codes an exhibit and a host share.
**XZBT-compatible exhibit** — any exhibit implementing the XZBT Exhibit Contract.
**Standalone exhibit** — an exhibit's normal, complete, independently usable form, with no host attached.
**XZBT-NGN Exhibit Engine** — the separate orchestration, integration, recording, authoring, and administration layer that connects to one or more exhibits through the contract; never required for standalone exhibit operation.
**Canonical target** — a publicly registered, dotted-ID addressable unit of exhibit state or action.
**State** — a target kind representing persistent value (boolean, string, or bounded scalar).
**Range** — a target kind representing persistent bounded numeric state, with `min`/`max`/`step`.
**Selection** — a target kind representing a persistent value chosen from a declared set of options.
**Impulse** — a target kind representing a non-persistent action or event; never part of a restorable snapshot.
**Capability** — a discoverable, stateful description of subsystem availability (`unsupported`/`available`/`loading`/`ready`/`busy`/`error`).
**stateRevision** — a monotonically increasing counter over committed persistent-state history, incremented at most once per mutation transaction.
**registryRevision** — a counter over the current set and metadata of discoverable targets; should not change for ordinary contextual availability shifts.
**Sequence** — a per-session, monotonically increasing counter over all emitted events, reset on new session.
**Session** — the negotiated context, established by `hello`, under which all other contract requests after the handshake must carry a valid `sessionId`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,865 @@
# SciFi-XZBT XZBT Contract Implementation Plan
## Version 5.2
**Status:** Proposed implementation plan
**Product:** SciFi-XZBT
**Contract:** XZBT Exhibit Contract 5.2
**Primary goal:** Make the existing standalone exhibit contract-addressable without turning it into XZBT-NGN.
---
## 1. Purpose
SciFi-XZBT will implement the XZBT Exhibit Contract while remaining a self-contained standalone HTML exhibit.
Version 5.2 is grounded in the v5.1 codebase review and deliberately corrects the remaining ambiguity around:
- current control-bus inventory;
- absolute state versus toggles;
- mutation/event emission;
- state revision semantics;
- observation versus viewport-frame control;
- universe/preset behavior;
- display ticker behavior;
- host validation;
- packaging-runtime compatibility.
The implementation should extend the real application foundation rather than create a parallel control system.
---
## 2. Standalone Guarantee
SciFi-XZBT MUST continue to operate normally without:
- XZBT-NGN;
- a host handshake;
- a web server;
- webhooks;
- external telemetry;
- scenario files;
- external automation.
The contract layer is dormant when no host is attached.
---
## 3. Existing Verified Foundation and Verification Boundary
The current codebase is known to include:
- `XZBTControlBus`;
- MIDI routing through that bus;
- a set of range controls;
- a set of action controls;
- speech/TTS;
- universe/preset setters;
- universe events;
- soundboard actions;
- Observation mode;
- an Observation viewport-frame toggle;
- existing bus listeners;
- no general host bridge;
- no general scenario/recording system;
- no structured fictional telemetry model.
### 3.1 Control-bus inventory safeguard
Review materials conflict on the exact total count.
One review states **22 targets: 14 range + 8 action**, but the same review explicitly names only seven action targets:
```text
master-play
fnc-enable
watch-experience
observation-viewport
red-alert
generate-announcement
mute
```
Therefore v5.2 MUST NOT hard-code 21 or 22 as a normative implementation fact.
Phase 0 MUST enumerate the actual `register()` / `rangeTarget()` calls in the target commit and produce the authoritative inventory.
The implementation plan below accounts for the known named targets and requires any additional verified target to be added before coding proceeds.
---
## 4. Scope
Version 5.2 implementation includes:
- canonical external target registry;
- direct adapters to existing setters where possible;
- new absolute setters where required;
- `describe`;
- target descriptors;
- state snapshot;
- state revision;
- event stream;
- source normalization;
- capability reporting;
- host attachment;
- explicit speech target;
- sound/event exposure;
- universe/preset exposure;
- safe ticker text exposure;
- compatibility with current MIDI and native UI behavior.
Structured telemetry remains optional and does not block first conformance.
---
## 5. Explicitly Out of Scope
The normal SciFi-XZBT HTML will not gain:
- scenario authoring;
- general scenario file loading;
- scenario recording;
- scenario library management;
- timeline editing;
- webhook endpoints;
- third-party connectors;
- Streamer.bot integration;
- Internet-facing authentication;
- remote administration;
- external telemetry acquisition;
- scenario scheduling;
- scenario chaining;
- host-side queue management;
- NGN administration;
- casting server functionality.
These remain XZBT-NGN responsibilities.
---
## 6. Canonical External Target Registry
The external registry is the single source of truth for:
- `describe`;
- validation;
- state readers;
- state writers;
- event target IDs;
- NGN discovery.
Existing internal IDs are not renamed.
### 6.1 Known range mappings
The following known range targets are pure external-to-internal mappings in concept, but their current synthetic-DOM application path must be replaced by direct setter calls during convergence.
```text
mix.master -> master-vol
mix.hull.level -> hull-vol
mix.hull.frequency -> hull-freq
mix.hull.cutoff -> hull-cutoff
mix.drive.level -> warp-vol
mix.drive.pulse-rate -> warp-bpm
mix.drive.carrier -> warp-carrier
mix.environment.level -> air-vol
mix.environment.cutoff -> air-cutoff
mix.telemetry.level -> telemetry-vol
mix.telemetry.density -> telemetry-density
speech.robot-amount -> ai-robot-amount
fnc.level -> fnc-level
view.activity -> observation-activity
```
Phase 0 MUST confirm all 14 names against the repository before implementation.
### 6.2 Known action/state adapters
These are not simple renames:
```text
transport.playing
transport.muted
fnc.enabled
view.observation
view.viewport-frame
event.alert-red
alert.active
speech.say
```
Known internal candidates include:
```text
master-play
mute
fnc-enable
watch-experience
observation-viewport
red-alert
generate-announcement
```
The external semantics are defined below and MUST NOT be implemented as blind DOM clicks.
### 6.3 Universe and preset selection
Add new canonical targets:
```text
universe.selected
preset.selected
```
These wrap the existing verified universe/preset setter functions.
They are new contract registrations, not mappings of pre-existing bus targets.
---
## 7. Absolute State Semantics
Persistent contract state MUST be idempotent.
### 7.1 Transport playing
Add:
```text
setPlaying(on, source)
```
It MUST:
- read current playing state;
- do nothing on a no-op;
- await startup/shutdown transition completion;
- not silently drop a request because a transition is already in progress;
- surface failure to the contract adapter.
### 7.2 Transport muted
Add:
```text
setMuted(on, source)
```
It MUST be absolute and idempotent.
### 7.3 FNC enabled
Use the existing absolute FNC setter directly where verified.
Do not synthesize a DOM click.
### 7.4 Observation mode
`view.observation` means entering or exiting the overall Observation experience.
Use existing absolute enter/exit functions where available.
Add a readable getter for current Observation state.
### 7.5 Viewport frame
`view.viewport-frame` means the viewport/bezel-frame state inside Observation.
It is separate from `view.observation`.
### 7.6 Alert
`event.alert-red` is an impulse.
`alert.active` is persistent state, preferably a selection:
```text
none
red
yellow
```
The two semantics MUST remain separate.
---
## 8. Fixed Target Catalog Decision
SciFi-XZBT v5.2 adopts a **fixed canonical target catalog**.
Universe-specific actions and sound effects remain discoverable across universe changes.
If a target is not valid in the current context, an attempt to invoke it returns `CAPABILITY_UNAVAILABLE` or another appropriate context error.
A universe change SHOULD NOT remove and re-add large sections of the target registry.
Therefore `registryRevision` remains stable unless target definitions themselves actually change.
This prevents unnecessary NGN re-description churn.
---
## 9. Describe Implementation
Add a contract description provider containing:
- exhibit identity;
- build/version metadata;
- contract version;
- registry revision;
- state revision;
- capability descriptors;
- target descriptors.
The canonical external registry is the source of truth.
Descriptors MUST NOT be duplicated in separate hand-maintained tables.
---
## 10. State Snapshot
Create one authoritative function that returns contract-visible persistent state.
Initial expected fields include:
```text
transport.playing
transport.muted
mix.master
mix.hull.level
mix.hull.frequency
mix.hull.cutoff
mix.drive.level
mix.drive.pulse-rate
mix.drive.carrier
mix.environment.level
mix.environment.cutoff
mix.telemetry.level
mix.telemetry.density
speech.robot-amount
fnc.enabled
fnc.level
view.observation
view.viewport-frame
view.activity
alert.active
universe.selected
preset.selected
```
Phase 0 must verify each read path.
Known required additions include:
- readable Observation state;
- canonical universe getter;
- canonical preset getter;
- canonical alert-state reader if not already directly exposed.
---
## 11. Universe and Preset Behavior
When `universe.selected` changes:
1. validate the universe ID against the real registry;
2. apply the universe;
3. select that universe's defined default preset;
4. commit both values in one mutation transaction;
5. emit the resulting selection/state events with one resulting `stateRevision`.
The system MUST NOT leave `preset.selected` pointing at a preset invalid for the current universe.
Unknown universe or preset IDs return `INVALID_VALUE`.
---
## 12. Mutation Chokepoint
All contract-visible persistent mutations MUST pass through one canonical mutation service in `js/app.js`.
Recommended conceptual API:
```text
applyMutation(targetId, value, source, context)
```
The implementation may use a class or object rather than this exact function name, but there MUST be one authoritative mutation path.
Responsibilities:
- validate target and value;
- call the direct underlying setter;
- detect no-op writes;
- commit one mutation transaction;
- increment `stateRevision` once when state changes;
- emit normalized state/selection events;
- preserve authoritative source;
- return success/failure.
The existing `XZBTControlBus.listeners` mechanism should be reused or extended as an event-subscription foundation where practical.
---
## 13. Remove Synthetic-DOM Mutation as the Canonical Path
Existing range bus application currently reaches real setters by assigning DOM values and dispatching synthetic `input` / `change` events.
Version 5.2 MUST separate programmatic state mutation from DOM event simulation.
The canonical mutation path MUST call the real setter directly.
UI listeners should call the canonical mutation path, not the other way around.
This prevents double application and makes source attribution reliable.
---
## 14. UI, MIDI, Hotkey, and Host Convergence
Required architecture:
```text
UI
MIDI
Hotkey
Host Contract
Scenario-originated local runtime
|
v
Canonical Mutation / Invoke Service
|
v
Existing SciFi-XZBT subsystem
```
Every path that changes contract-visible state MUST use the canonical service.
Every path that performs a contract-visible impulse MUST use the canonical invoke service.
The phrase "where practical" is removed for contract-visible behavior.
---
## 15. State Revision
SciFi-XZBT follows the contract's mutation-transaction rule.
One top-level mutation transaction produces at most one new `stateRevision`.
A no-op produces no revision increment.
The revision increments after commit and before resulting events are emitted.
Universe changes plus default-preset selection are one transaction.
Preset application may update multiple exposed values in one transaction.
---
## 16. Event Surface
SciFi-XZBT MUST emit normalized events.
At minimum:
```text
state.changed
action.executed
selection.changed
capability.changed
registry.changed
error
```
Each event includes:
- sequence;
- timestamp;
- target where applicable;
- value or args;
- authoritative source;
- state revision where applicable;
- correlation ID when directly caused by a host request.
Session event sequence resets on new contract session.
---
## 17. Source Normalization
Use:
```text
ui
midi
hotkey
host
scenario
internal
system
```
A host-supplied `source` is ignored.
The bridge assigns `host`.
Packaged scenario runtime assigns `scenario`.
Existing MIDI routing retains `midi`.
---
## 18. Speech
Expose:
```text
speech.say
```
This calls the existing explicit `speak(text)` implementation.
Do not confuse it with "generate announcement."
If generated-announcement behavior is exposed separately, it receives a distinct impulse target such as:
```text
speech.generate-announcement
```
SciFi-XZBT remains responsible for:
- TTS loading;
- preferred engine;
- fallback synthesis;
- selected voice;
- robotic voice processing;
- spoken-unit normalization;
- interruption behavior;
- playback.
### 18.1 Speech capability state
Add a readable capability state field to the generative/speech subsystem.
Use:
```text
available
loading
ready
busy
error
```
Use `unsupported` only when speech is not implemented at all.
### 18.2 Queue behavior
Current behavior interrupts prior speech.
Preserve that behavior in the first implementation.
A successful `speech.say` means accepted and started, not completed.
### 18.3 Length limit
Set a conservative maximum accepted text length.
Recommended first limit:
```text
2000 characters
```
This limit is an implementation constant and may be tuned later.
Over-limit requests return `INVALID_VALUE`.
---
## 19. Sound Effects
Build a verified stable public inventory from the current soundboard and synthesis dispatch.
Do not automatically expose every internal synthesis helper.
The fixed external catalog may include targets that are contextually unavailable in the current universe.
Unavailable invocation returns a defined error instead of removing the target.
---
## 20. Universe Events
Build a verified stable public event catalog from current universe `events[]` definitions and actual dispatch behavior.
Do not treat examples as authoritative inventory.
Keep persistent alert state separate from alert impulses.
---
## 21. Display Ticker
Expose:
```text
display.ticker
```
as a **transient write**.
Semantics:
- text is immediately displayed;
- it remains subject to normal exhibit ticker replacement;
- the contract does not claim persistence;
- the exhibit SHOULD emit a state/action event indicating the accepted write;
- a future persistent-host-caption feature would use a different target.
Use safe `textContent`-style assignment.
Recommended maximum:
```text
512 characters
```
Over-limit input returns `INVALID_VALUE`.
Do not invent `display.status` or `display.transient` until real surfaces exist.
---
## 22. Telemetry
The current codebase does not yet have structured fictional display telemetry fields.
Version 5.2 keeps two stages.
### Stage A - Contract readiness
Ensure future `telemetry.*` targets can be added cleanly.
### Stage B - New exhibit feature
Later create real structured telemetry fields with meaningful visible representation.
Only then advertise writable telemetry capabilities.
Structured telemetry is not required for first conformance.
---
## 23. Host Attachment
Initial host attachment uses:
1. same-origin parent/frame `postMessage`;
2. optional explicit trusted wrapper binding.
The bridge MUST validate:
- `event.origin`;
- `event.source`;
- contract version;
- session;
- message type;
- target;
- value type;
- numeric bounds;
- args schema;
- unexpected args keys;
- selection membership;
- capability state;
- text length limits.
The base exhibit does not automatically connect to remote services.
No webhook listener belongs in the HTML.
---
## 24. Capability Model
Initial capabilities may include:
```text
audio
speech
midi
observation
display-text
```
Do not advertise telemetry-write until it exists.
A target's `requires` list references these capability IDs.
Context-specific target unavailability does not require capability removal.
---
## 25. Registry Revision
Because v5.2 uses a fixed canonical target catalog, ordinary universe changes SHOULD NOT change `registryRevision`.
Increment it only when:
- a target is added or removed;
- target descriptor metadata changes;
- option metadata genuinely changes in a way that changes the registry contract.
Use contextual availability errors rather than registry churn where practical.
---
## 26. Build and Packaging
The existing PowerShell packaging process remains authoritative for the ordinary single-file standalone artifact.
Contract integration MUST preserve the packaging script's expected include patterns.
Ordinary free packaging remains independent of XZBT-NGN.
Future NGN scenario packaging is separate.
---
## 27. Implementation Phases
### Phase 0 - Repository verification
Produce an authoritative inventory from the target commit.
Required output:
- every control-bus registration;
- all internal IDs;
- range/action counts;
- direct backing setter;
- current source paths;
- universe/preset accessors;
- event/sound catalogs;
- package-script assumptions.
Do not proceed while target count remains disputed.
### Phase 1 - Canonical registry
Create the single source of truth for canonical external target descriptors and internal adapters.
### Phase 2 - Direct setters and absolute state
Implement direct range mutation and absolute setters for toggle-like state.
### Phase 3 - Mutation service
Implement the canonical mutation/invoke chokepoint and transaction semantics.
### Phase 4 - State and discovery
Implement:
- `describe`;
- `state.get`;
- `stateRevision`;
- `registryRevision`;
- capabilities.
### Phase 5 - Input convergence
Rewrite all contract-visible UI, MIDI, and hotkey mutation paths to call the canonical service.
This is a substantial refactor, not a small wiring task.
No contract-visible inline listener may bypass the canonical mutation path at phase completion.
### Phase 6 - Event normalization
Publish normalized events with source, sequence, revision, and correlation.
### Phase 7 - Speech, SFX, universe events, ticker
Expose verified exhibit functions through canonical targets.
### Phase 8 - Host bridge
Implement same-origin messaging and validation.
### Phase 9 - Standalone verification
Confirm complete application behavior without any host.
### Phase 10 - Optional structured telemetry
Only after intentionally designing real telemetry fields.
---
## 28. First Contract-Compatible Acceptance Criteria
The first compatible release succeeds when:
- SciFi-XZBT still runs normally by itself;
- a test host completes handshake;
- a test host calls `describe`;
- the authoritative target inventory matches the repository;
- persistent state can be read;
- representative ranges can be set directly;
- toggle-like targets are absolute and idempotent;
- setting a boolean state to its current value twice leaves the same state;
- universe changes select a valid default preset;
- Observation and viewport-frame controls are distinct;
- representative sound and visual impulses can be invoked;
- `speech.say` accepts explicit text;
- `display.ticker` behaves according to its transient contract;
- UI, MIDI, hotkey, and host changes emit normalized events;
- event source is accurate;
- session sequence and state revision work;
- no-op sets do not increment revision;
- malformed commands fail safely;
- unexpected origins are ignored;
- no scenario engine or webhook system has been added.
Structured telemetry is not required.
---
## 29. Future Telemetry Acceptance Criteria
When structured telemetry is later added:
- each field has a real visible or meaningful exhibit representation;
- metadata is discoverable;
- read-only and writable fields are explicit;
- external ownership prevents immediate simulation overwrite;
- stale or released ownership returns cleanly to simulation;
- NGN remains responsible for acquiring real telemetry.
---
## 30. Architectural Test
When considering a future feature:
**Does SciFi-XZBT need this to remain a complete standalone exhibit?**
If yes, it may belong in SciFi-XZBT.
**Does the feature coordinate, automate, connect, record, author, distribute, or externally control the exhibit?**
If yes, it belongs primarily in XZBT-NGN.
---
## 31. Version 5.2 Summary
Version 5.2 does not change the Version 5 architecture.
It makes the SciFi-XZBT implementation plan concrete enough to begin coding after Phase 0 verification.
The implementation must normalize the real application rather than pretending that existing DOM-click proxies and synthetic events already satisfy the contract.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "xzbt-ngn",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server/serve.js",
"test": "node --test"
},
"engines": { "node": ">=22" }
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>XZBT-NGN Exhibit Engine</title><link rel="stylesheet" href="/public/style.css"></head>
<body>
<header><h1>XZBT-NGN Exhibit Engine</h1><p>Contract 5.2 · Single-exhibit engineering console</p></header>
<main>
<section><h2>Connection</h2>
<form id="connection"><label for="exhibit-url">Exhibit URL (same origin)</label><div class="connection-row"><input id="exhibit-url" placeholder="/test-fixtures/reference-exhibits/…/index.html" required><button>Load exhibit</button><button id="reconnect" type="button" disabled>Reconnect</button><button id="disconnect" type="button">Disconnect</button></div></form>
<p id="status" role="status">Disconnected</p><button id="refresh" disabled>Refresh state</button>
<p>Only load trusted local exhibits. Required capabilities in available/loading states may accept requests through an exhibit fallback; the exhibit decides.</p>
<div id="frame-container"></div>
</section>
<section><h2>Session and exhibit</h2><pre id="metadata">No negotiated session.</pre><h3>Capabilities</h3><pre id="capabilities">[]</pre></section>
<section><h2>Discovered targets <span id="target-count"></span></h2><div id="catalog">Connect to discover the catalog.</div></section>
<section><h2>Current persistent state</h2><p>Last reported exhibit values. This host view may be uncertain while resynchronizing.</p><pre id="state">{}</pre></section>
<section><h2>Incoming events</h2><p>Latest 200 events, including source, sequence, revision, and correlation when supplied.</p><pre id="events" aria-label="Incoming events">[]</pre></section>
<section><h2>Protocol and errors</h2><pre id="errors" role="log">[]</pre></section>
</main>
<script type="module" src="/src/ui.js"></script>
</body></html>
+15
View File
@@ -0,0 +1,15 @@
:root { font: 15px/1.5 system-ui, sans-serif; color: #e5ebf1; background: #121921; color-scheme: dark; }
body { margin: 0; } header, main { max-width: 1280px; margin: auto; padding: 20px; }
header { padding-bottom: 0; } h1 { font-size: 25px; } h2 { font-size: 19px; margin-top: 0; }
h3 { font-size: 16px; } p { color: #b7c4d1; } section { padding: 20px; margin-bottom: 20px; background: #1a2530; border: 1px solid #354658; border-radius: 6px; }
button, input, select, textarea { font: inherit; padding: 8px; border: 1px solid #587086; border-radius: 4px; }
button { background: #224b65; color: #fff; cursor: pointer; } button:disabled { opacity: .5; cursor: default; }
input, select, textarea { background: #111d28; color: #fff; max-width: 100%; box-sizing: border-box; }
label { display: block; margin: 8px 0; } input[type=checkbox] { margin-right: 8px; }
.connection-row { display: flex; flex-wrap: wrap; gap: 8px; } #exhibit-url { flex: 1; min-width: 220px; }
iframe { width: 100%; height: 390px; margin-top: 15px; border: 1px solid #587086; background: white; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #111d28; padding: 12px; max-height: 350px; overflow: auto; }
.target { border-top: 1px solid #354658; padding: 16px 0; } .target h3 { margin: 0; }
.controls { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; } .controls label { min-width: 150px; }
.notice { color: #ffcd7a; } details { margin: 8px 0; } summary { cursor: pointer; } output { display: block; margin: 8px 0; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid #7cceff; outline-offset: 2px; }
+36
View File
@@ -0,0 +1,36 @@
import http from 'node:http';
import { readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../', import.meta.url));
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.wav': 'audio/wav', '.mp3': 'audio/mpeg' };
export function createServer() {
return http.createServer(async (req, res) => {
try {
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); res.end('Method not allowed'); return; }
const url = new URL(req.url, 'http://localhost');
const pathname = decodeURIComponent(url.pathname);
// Serve only the product surface and explicitly owned fixture subtree.
const route = pathname === '/' ? '/public/index.html' : pathname;
const mount = ['/public/', '/src/', '/test-fixtures/'].find(prefix => route.startsWith(prefix));
if (!mount || route.includes('\\') || route.includes('\0')) throw new Error('Not found');
const base = await realpath(path.join(root, mount));
let candidate = await realpath(path.join(root, route));
const relative = path.relative(base, candidate);
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Not found');
if ((await stat(candidate)).isDirectory()) candidate = await realpath(path.join(candidate, 'index.html'));
const finalRelative = path.relative(base, candidate);
if (finalRelative.startsWith('..') || path.isAbsolute(finalRelative)) throw new Error('Not found');
const content = await readFile(candidate);
res.writeHead(200, { 'Content-Type': mime[path.extname(candidate)] ?? 'application/octet-stream',
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Length': content.length });
res.end(req.method === 'HEAD' ? undefined : content);
} catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); }
});
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const port = Number(process.env.PORT ?? 4173);
createServer().listen(port, '127.0.0.1', () => console.log(`XZBT-NGN: http://127.0.0.1:${port}`));
}
+199
View File
@@ -0,0 +1,199 @@
import { ProtocolError, record, counter, check, validateCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
const events = new Set(['state.changed', 'selection.changed', 'action.executed', 'capability.changed', 'registry.changed', 'error']);
const responses = { hello: 'hello.result', describe: 'describe.result', 'state.get': 'state.result', set: 'set.result', invoke: 'invoke.result' };
export class ExhibitHost {
constructor({ timeoutMs = 5000, debounceMs = 100 } = {}) {
this.timeoutMs = timeoutMs; this.debounceMs = debounceMs;
this.listeners = new Set(); this.pending = new Map(); this.logs = [];
this.generation = 0; this.requestNumber = 0; this.resetView();
}
resetView() {
this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
this.catalog = []; this.capabilities = []; this.registryRevision = null; this.stateRevision = null;
this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false;
}
subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
changed() { for (const fn of this.listeners) fn(this); }
log(code, message, detail = null) {
this.logs.push({ time: new Date().toISOString(), code, message, detail });
if (this.logs.length > 200) this.logs.shift();
this.changed();
}
report(error) { this.log(error.code ?? 'HOST_ERROR', error.message); }
disconnect() {
this.generation++;
clearTimeout(this.refreshTimer);
this.unsubscribe?.(); this.transport?.close(); this.transport = null;
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('DISCONNECTED', 'Session ended.')); }
this.pending.clear(); this.resetView(); this.changed();
}
async connect(transport) {
this.disconnect(); this.transport = transport;
this.unsubscribe = transport.subscribe(message => this.receive(message));
this.status = 'negotiating'; this.changed();
const generation = this.generation;
try {
const hello = await this.request('hello', { host: { name: 'XZBT-NGN', version: '0.1.0' }, supportedContractMajors: [5] });
if (generation !== this.generation) return;
check(hello.contract?.major === 5 && counter(hello.contract?.minor), 'Exhibit negotiated an unsupported contract.', 'UNSUPPORTED_VERSION');
check(typeof hello.sessionId === 'string' && hello.sessionId.length > 0 && record(hello.exhibit), 'Invalid hello result.');
this.sessionId = hello.sessionId; this.contract = hello.contract; this.exhibit = hello.exhibit;
this.status = 'connected'; this.log('SESSION', 'Exhibit session established.', { sessionId: this.sessionId });
await this.refresh(true);
} catch (error) {
if (generation === this.generation) { this.status = 'error'; this.report(error); }
throw error;
}
}
request(type, payload = {}) {
if (!this.transport || (type !== 'hello' && (!this.sessionId || this.status !== 'connected'))) {
return Promise.reject(new ProtocolError('INVALID_SESSION', 'Connect an exhibit session first.'));
}
const requestId = `ngn-${this.generation}-${++this.requestNumber}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(new ProtocolError('TIMEOUT', `${type} timed out; its outcome may be unknown.`));
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, timer, type });
try {
this.transport.send({ xzbt: '5.2', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
} catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
});
}
receive(message) {
try {
check(record(message) && typeof message.type === 'string' && typeof message.xzbt === 'string', 'Malformed contract envelope.');
const pending = typeof message.requestId === 'string' && this.pending.get(message.requestId);
if (pending && !(message.type === 'error' && counter(message.sequence))) {
if (pending.type !== 'hello' && message.sessionId !== this.sessionId) {
this.log('STALE_SESSION', 'Ignored response for another session.'); return;
}
clearTimeout(pending.timer); this.pending.delete(message.requestId);
if (message.type === 'error') {
const error = new ProtocolError(message.error?.code ?? 'INVALID_MESSAGE', message.error?.message ?? 'Malformed error response.');
if (error.code === 'INVALID_SESSION') this.invalidate();
pending.reject(error); return;
}
if (message.type !== responses[pending.type] || (['set', 'invoke'].includes(pending.type) && message.ok !== true)) {
pending.reject(new ProtocolError('INVALID_MESSAGE', 'Unexpected response type or success flag.')); return;
}
pending.resolve(message); return;
}
if (!this.sessionId || message.sessionId !== this.sessionId) {
this.log('STALE_SESSION', 'Ignored message outside the negotiated session.'); return;
}
check(events.has(message.type), 'Unknown event or uncorrelated response.');
check(counter(message.sequence) && Number.isFinite(message.timestamp), 'Invalid event sequence or timestamp.');
if (this.sequence !== null && message.sequence !== this.sequence + 1) {
this.log('SEQUENCE_GAP', `Expected ${this.sequence + 1}, received ${message.sequence}.`);
this.scheduleRefresh(false);
if (message.sequence <= this.sequence) return;
}
this.sequence = message.sequence;
this.eventLog.push(message); if (this.eventLog.length > 200) this.eventLog.shift();
if (['state.changed', 'selection.changed'].includes(message.type)) {
if (this.snapshotEvents) this.snapshotEvents.push(message);
this.applyStateEvent(message);
}
if (message.type === 'registry.changed' || message.type === 'capability.changed') this.scheduleRefresh(true);
if (message.type === 'error') {
this.log(message.error?.code ?? 'INVALID_MESSAGE', message.error?.message ?? 'Malformed error event.', message);
if (message.error?.code === 'INVALID_SESSION') this.invalidate();
}
this.changed();
} catch (error) { this.report(error); if (this.sessionId) this.scheduleRefresh(false); }
}
invalidate() {
this.status = 'invalid session'; this.sync = 'reconnect required';
clearTimeout(this.refreshTimer);
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('INVALID_SESSION', 'Reconnect to establish a new session.')); }
this.pending.clear(); this.changed();
}
applyStateEvent(event, replay = false) {
const target = this.catalog.find(t => t.id === event.target);
check(target && target.readable && target.kind !== 'impulse' && Object.hasOwn(event, 'value')
&& counter(event.stateRevision), 'Invalid persistent-state event.');
validateReportedValue(target, event.value);
if (this.stateRevision !== null && event.stateRevision < this.stateRevision) return;
if (!replay && this.stateRevision !== null && event.stateRevision > this.stateRevision + 1) {
this.log('REVISION_GAP', `State revision advanced from ${this.stateRevision} to ${event.stateRevision}.`);
this.scheduleRefresh(false);
}
this.values.set(event.target, event.value); this.stateRevision = event.stateRevision;
}
scheduleRefresh(describe) {
if (this.status !== 'connected') return;
this.sync = 'resynchronizing'; this.refreshWanted ||= describe;
clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => {
const wanted = this.refreshWanted; this.refreshWanted = false;
this.refresh(wanted).catch(error => this.report(error));
}, this.debounceMs);
}
async refresh(describe = false) {
if (this.refreshing) {
const generation = this.generation;
await this.refreshing;
if (generation !== this.generation) return;
return this.refresh(describe);
}
const generation = this.generation;
const work = async () => {
this.sync = 'resynchronizing'; this.changed();
if (describe) {
const description = await this.request('describe');
if (generation !== this.generation) return;
validateCatalog(description);
this.catalog = description.targets; this.capabilities = description.capabilities;
this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
this.changed();
}
this.snapshotEvents = [];
const baseline = this.stateRevision;
const snapshot = await this.request('state.get');
if (generation !== this.generation) return;
check(counter(snapshot.stateRevision) && record(snapshot.values), 'Invalid state snapshot.');
check(baseline === null || snapshot.stateRevision >= baseline, 'Snapshot revision regressed.');
const readable = new Set(this.catalog.filter(t => t.readable && t.kind !== 'impulse').map(t => t.id));
check(Object.keys(snapshot.values).every(id => readable.has(id)), 'Snapshot contains an unknown or non-readable target.');
check([...readable].every(id => Object.hasOwn(snapshot.values, id)), 'Snapshot omits readable persistent state.');
for (const target of this.catalog) if (readable.has(target.id)) validateReportedValue(target, snapshot.values[target.id]);
this.values = new Map(Object.entries(snapshot.values)); this.stateRevision = snapshot.stateRevision;
for (const event of this.snapshotEvents) this.applyStateEvent(event, true);
this.snapshotEvents = null; this.sync = 'synchronized'; this.changed();
};
this.refreshing = work();
try { await this.refreshing; }
catch (error) { if (generation === this.generation) { this.sync = 'uncertain'; this.snapshotEvents = null; } throw error; }
finally { if (generation === this.generation) { this.refreshing = null; this.changed(); } }
}
target(id) {
const target = this.catalog.find(t => t.id === id);
check(target, 'Unknown target.', 'UNKNOWN_TARGET'); return target;
}
unavailable(target) {
return target.requires.filter(id => {
const capability = this.capabilities.find(c => c.id === id);
return !capability || ['unsupported', 'error'].includes(capability.state);
});
}
async operate(type, id, data) {
try {
const target = this.target(id);
check(this.unavailable(target).length === 0, 'A required capability is unavailable.', 'CAPABILITY_UNAVAILABLE');
if (type === 'set') validateSet(target, data);
else { check(target.kind === 'impulse', 'Target is not invokable.', 'TARGET_NOT_INVOKABLE'); validateArgs(target, data); }
await this.request(type, { target: id, [type === 'set' ? 'value' : 'args']: data });
await this.refresh(false);
} catch (error) {
if (error.code === 'TIMEOUT') { this.sync = 'uncertain'; this.scheduleRefresh(false); }
this.report(error); throw error;
}
}
set(id, value) { return this.operate('set', id, value); }
invoke(id, args = {}) { return this.operate('invoke', id, args); }
}
+20
View File
@@ -0,0 +1,20 @@
// This adapter alone knows about Window, origins, and message events.
export function postMessageTransport(frame, ownWindow = window) {
const origin = ownWindow.location.origin;
if (origin === 'null' || new URL(frame.src, ownWindow.location.href).origin !== origin) {
throw new Error('The exhibit must be served from the same HTTP origin as NGN.');
}
const peer = frame.contentWindow;
const listeners = new Set();
const receive = event => {
if (event.origin === origin && event.source === peer) {
for (const listener of listeners) listener(event.data);
}
};
ownWindow.addEventListener('message', receive);
return {
send(message) { peer.postMessage(message, origin); },
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
close() { ownWindow.removeEventListener('message', receive); listeners.clear(); }
};
}
+127
View File
@@ -0,0 +1,127 @@
import { ExhibitHost } from './host.js';
import { argumentSchema } from './validation.js';
import { postMessageTransport } from './transport/post-message.js';
const host = new ExhibitHost();
const byId = id => document.getElementById(id);
const json = value => JSON.stringify(value, null, 2);
const rows = new Map();
let frame = null, renderedCatalog = null;
function element(tag, text, parent) {
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
parent?.append(node); return node;
}
function editor(spec, label, parent) {
const wrapper = element('label', label, parent);
const options = spec.enum;
const input = element(options ? 'select' : 'input', undefined, wrapper);
input.setAttribute('aria-label', label);
if (options) {
for (const [index, value] of options.entries()) {
const option = element('option', spec.optionLabels?.[index] ?? String(value), input); option.value = String(index);
}
} else {
input.type = spec.type === 'boolean' ? 'checkbox' : ['number', 'integer'].includes(spec.type) ? 'number' : 'text';
for (const key of ['min', 'max', 'step', 'minLength', 'maxLength']) if (spec[key] !== undefined) input[key] = spec[key];
if (input.type === 'number' && spec.step === undefined) input.step = spec.type === 'integer' ? '1' : 'any';
}
return {
input,
read() { return options ? options[Number(input.value)] : spec.type === 'boolean' ? input.checked
: ['number', 'integer'].includes(spec.type) ? (input.value === '' ? NaN : Number(input.value)) : input.value; },
write(value) {
if (options) input.value = String(options.findIndex(v => Object.is(v, value)));
else if (spec.type === 'boolean') input.checked = value === true;
else input.value = value ?? '';
}
};
}
function buildCatalog() {
rows.clear(); byId('catalog').replaceChildren();
for (const target of host.catalog) {
const section = element('article', undefined, byId('catalog')); section.className = 'target';
element('h3', target.label ?? target.id, section); element('code', target.id, section);
element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section);
const current = element('output', '', section);
const detail = element('details', undefined, section); element('summary', 'Target descriptor', detail); element('pre', json(target), detail);
const notice = element('p', '', section); notice.className = 'notice';
const form = element('form', undefined, section); form.className = 'controls'; form.noValidate = true;
let valueEditor, schemaError = '', argumentEditors = [];
if (target.kind === 'impulse') {
try {
for (const arg of argumentSchema(target)) {
const group = element('div', undefined, form);
let include = null;
if (!arg.required) { const l = element('label', `Include ${arg.label ?? arg.name}`, group); include = element('input', undefined, l); include.type = 'checkbox'; }
const field = editor(arg, arg.label ?? arg.name, group);
if (arg.description) element('p', arg.description, group);
argumentEditors.push({ arg, include, field });
}
} catch (error) { schemaError = error.message; }
} else if (target.writable) {
if (target.kind === 'selection') valueEditor = editor({ enum: target.options.map(o => o.value), optionLabels: target.options.map(o => o.label) }, 'New value', form);
else if (target.kind === 'range' || ['boolean', 'number', 'integer', 'string'].includes(target.valueType)) {
valueEditor = editor({ ...target, type: target.kind === 'range' ? 'number' : target.valueType }, 'New value', form);
} else schemaError = 'Unsupported state value type.';
}
let submit = null;
let row;
if (target.kind === 'impulse' || target.writable) {
submit = element('button', target.kind === 'impulse' ? 'Invoke' : 'Set', form);
form.addEventListener('submit', async event => {
event.preventDefault(); if (row.busy) return; row.busy = true; submit.disabled = true;
try {
if (target.kind === 'impulse') {
const args = Object.create(null);
for (const { arg, include, field } of argumentEditors) if (!include || include.checked) args[arg.name] = field.read();
await host.invoke(target.id, args);
} else await host.set(target.id, valueEditor.read());
} catch { /* Host reports every control failure in the visible console. */ }
row.busy = false; render();
});
}
row = { target, current, notice, submit, schemaError, valueEditor, initialized: false, busy: false };
rows.set(target.id, row);
}
}
function render() {
byId('status').textContent = `${host.status} · ${host.sync}`;
byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit,
registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence });
byId('capabilities').textContent = json(host.capabilities);
byId('state').textContent = json(Object.fromEntries(host.values));
byId('events').textContent = json(host.eventLog);
byId('errors').textContent = json(host.logs);
byId('target-count').textContent = `(${host.catalog.length})`;
byId('refresh').disabled = host.status !== 'connected';
byId('reconnect').disabled = !frame;
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
for (const row of rows.values()) {
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
row.valueEditor.write(host.values.get(row.target.id)); row.initialized = true;
}
const unavailable = host.unavailable(row.target);
row.notice.textContent = row.schemaError || (unavailable.length ? `Unavailable: ${unavailable.join(', ')}` : '');
if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0;
}
}
async function attach() {
if (!frame) return;
try { await host.connect(postMessageTransport(frame)); } catch (error) { if (!error.code) host.report(error); }
}
byId('connection').addEventListener('submit', event => {
event.preventDefault();
try {
const url = new URL(byId('exhibit-url').value, location.href);
if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) throw new Error('Enter a same-origin HTTP exhibit URL.');
host.disconnect(); frame?.remove();
frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
frame.addEventListener('load', attach); frame.src = url.href;
byId('frame-container').append(frame); render();
} catch (error) { host.report(error); }
});
byId('reconnect').addEventListener('click', attach);
byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); });
byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
host.subscribe(render); render();
+106
View File
@@ -0,0 +1,106 @@
export class ProtocolError extends Error {
constructor(code, message) { super(message); this.name = 'ProtocolError'; this.code = code; }
}
export const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
export const counter = value => Number.isSafeInteger(value) && value >= 0;
export function check(condition, message, code = 'INVALID_MESSAGE') {
if (!condition) throw new ProtocolError(code, message);
}
const types = ['string', 'number', 'integer', 'boolean'];
export function argumentSchema(target) {
const args = target.arguments ?? [];
check(Array.isArray(args), 'Unsupported argument schema: arguments must be an array.', 'UNSUPPORTED_SCHEMA');
const names = new Set();
for (const arg of args) {
check(record(arg) && typeof arg.name === 'string' && !names.has(arg.name)
&& types.includes(arg.type) && typeof arg.required === 'boolean',
'Unsupported argument schema: invalid name, type, or required field.', 'UNSUPPORTED_SCHEMA');
names.add(arg.name);
for (const key of ['min', 'max', 'step']) {
check(arg[key] === undefined || (Number.isFinite(arg[key]) && ['number', 'integer'].includes(arg.type)
&& (key !== 'step' || arg[key] > 0)), `Unsupported argument ${key}.`, 'UNSUPPORTED_SCHEMA');
}
for (const key of ['minLength', 'maxLength']) {
check(arg[key] === undefined || (counter(arg[key]) && arg.type === 'string'),
`Unsupported argument ${key}.`, 'UNSUPPORTED_SCHEMA');
}
check(arg.min === undefined || arg.max === undefined || arg.min <= arg.max, 'Inverted bounds.', 'UNSUPPORTED_SCHEMA');
check(arg.minLength === undefined || arg.maxLength === undefined || arg.minLength <= arg.maxLength,
'Inverted length bounds.', 'UNSUPPORTED_SCHEMA');
check(arg.enum === undefined || (Array.isArray(arg.enum) && arg.enum.every(v => matches(v, arg.type))),
'Unsupported argument enum.', 'UNSUPPORTED_SCHEMA');
}
return args;
}
function matches(value, type) {
return type === 'integer' ? Number.isSafeInteger(value)
: type === 'number' ? Number.isFinite(value) : typeof value === type;
}
export function validateValue(value, spec) {
const fail = message => check(false, message, 'INVALID_VALUE');
if (!matches(value, spec.type)) fail(`Expected ${spec.type}.`);
if (spec.enum && !spec.enum.includes(value)) fail('Value is not a declared option.');
if (typeof value === 'number') {
if (spec.min !== undefined && value < spec.min) fail(`Minimum is ${spec.min}.`);
if (spec.max !== undefined && value > spec.max) fail(`Maximum is ${spec.max}.`);
if (spec.step !== undefined) {
const units = (value - (spec.min ?? 0)) / spec.step;
if (Math.abs(units - Math.round(units)) > 1e-7) fail(`Value must follow step ${spec.step}.`);
}
}
if (typeof value === 'string') {
if (spec.minLength !== undefined && value.length < spec.minLength) fail(`Minimum length is ${spec.minLength}.`);
if (spec.maxLength !== undefined && value.length > spec.maxLength) fail(`Maximum length is ${spec.maxLength}.`);
}
}
export function validateArgs(target, args) {
check(record(args), 'Invoke args must be an object.', 'INVALID_VALUE');
const schema = argumentSchema(target);
const known = new Set(schema.map(a => a.name));
for (const key of Object.keys(args)) check(known.has(key), `Undeclared argument: ${key}`, 'INVALID_VALUE');
for (const arg of schema) {
const present = Object.hasOwn(args, arg.name);
check(present || !arg.required, `Required argument: ${arg.name}`, 'INVALID_VALUE');
if (present) validateValue(args[arg.name], arg);
}
}
export function validateSet(target, value) {
check(target.kind !== 'impulse', 'Impulse targets cannot be set.', 'TARGET_NOT_SETTABLE');
check(target.writable, 'Target is read-only.', 'TARGET_READ_ONLY');
if (target.kind === 'selection') {
check(target.options.some(o => Object.is(o.value, value)), 'Value is not a declared option.', 'INVALID_VALUE');
} else validateValue(value, { ...target, type: target.kind === 'range' ? 'number' : target.valueType });
}
export function validateReportedValue(target, value) {
// Read-only targets use the same declared value shape without requiring writability.
if (target.kind === 'selection') {
check(target.options.some(o => Object.is(o.value, value)), `Invalid reported value for ${target.id}.`);
} else if (target.kind === 'range' || types.includes(target.valueType)) {
try { validateValue(value, { ...target, type: target.kind === 'range' ? 'number' : target.valueType }); }
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
}
}
export function validateCatalog(message) {
check(record(message.exhibit) && record(message.contract) && message.contract.major === 5,
'Invalid description metadata.');
check(counter(message.registryRevision) && counter(message.stateRevision), 'Invalid description revisions.');
check(Array.isArray(message.targets) && Array.isArray(message.capabilities), 'Invalid catalog arrays.');
const ids = new Set();
for (const t of message.targets) {
check(record(t) && typeof t.id === 'string' && /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(t.id)
&& !ids.has(t.id) && ['state', 'range', 'selection', 'impulse'].includes(t.kind)
&& typeof t.readable === 'boolean' && typeof t.writable === 'boolean'
&& Array.isArray(t.requires) && t.requires.every(id => typeof id === 'string'), 'Invalid target descriptor.');
ids.add(t.id);
if (t.kind === 'range') check(Number.isFinite(t.min) && Number.isFinite(t.max) && t.min <= t.max
&& Number.isFinite(t.step) && t.step > 0, 'Invalid range descriptor.');
if (t.kind === 'selection') check(Array.isArray(t.options)
&& t.options.every(o => record(o) && Object.hasOwn(o, 'value')), 'Invalid selection descriptor.');
}
const caps = new Set();
for (const c of message.capabilities) {
check(record(c) && typeof c.id === 'string' && !caps.has(c.id) && typeof c.state === 'string', 'Invalid capability.');
caps.add(c.id);
}
for (const t of message.targets) check(t.requires.every(id => caps.has(id)), 'Undeclared required capability.');
}
+26
View File
@@ -0,0 +1,26 @@
# Reference fixture provenance
The owner selected the `abacusai` set on 2026-09-13. The source is
`G:/.vibe/SciFi-XZBT/exhibit-test/abacusai/reference-exhibits/`.
Only the three exhibit directories, shared dependencies,
and upstream README were copied. The external source is unchanged.
These files are NGN test fixtures, not product code. Their upstream README
describes upstream verification and is not evidence of NGN verification.
Local compatibility corrections:
- Eight read-only descriptors used `kind: telemetry`, which is not
one of Contract 5.2's four kinds. They now use `kind: state` with their
existing value types; IDs and backing readings are unchanged.
- Planetarium's action argument metadata was named `args`; it is now named
`arguments` under the owner's 5.2 clarification.
- The shared fixture invoke path now enforces declared argument types,
required fields, allowed keys, enums, and numeric/string constraints.
- Planetarium's magnitude reader and native dispatch still used the old
camelCase ID. They now match its existing canonical `sky.magnitude-limit`
descriptor, fixing a null snapshot value and failed native writes.
NGN has no fixture-specific target logic. These corrections apply only to
the local fixture copies. No upstream fixes or independent conformance
certification are claimed.
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>NGN real exhibit verification</title><link rel="stylesheet" href="/public/style.css"></head>
<body><main><h1>NGN real exhibit verification</h1><p>This uses the production host and transport against the three copied reference exhibits. It does not replace the separate admin UI and native gesture checks.</p><button id="run">Run verification</button><pre id="results" role="log">Not run.</pre><div id="fixtures"></div></main><script type="module" src="./host-verification.js"></script></body></html>
+78
View File
@@ -0,0 +1,78 @@
import { ExhibitHost } from '/src/host.js';
import { postMessageTransport } from '/src/transport/post-message.js';
const results = document.querySelector('#results');
const run = document.querySelector('#run');
function assert(condition, message) { if (!condition) throw new Error(message); }
async function rejected(operation, code) {
try { await operation(); } catch (error) { assert(error.code === code, `Expected ${code}, got ${error.code}`); return; }
throw new Error(`Expected rejection ${code}`);
}
async function raw(transport, message) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { off(); reject(new Error('Raw request timeout')); }, 3000);
const off = transport.subscribe(response => {
if (response.requestId !== message.requestId) return;
clearTimeout(timeout); off(); resolve(response);
});
transport.send(message);
});
}
run.addEventListener('click', async () => {
run.disabled = true; results.textContent = ''; document.querySelector('#fixtures').replaceChildren();
let passed = 0;
for (const name of ['aquarium', 'planetarium', 'haunted-house']) {
const host = new ExhibitHost();
const frame = document.createElement('iframe'); frame.title = `${name} test exhibit`;
try {
const loaded = new Promise(resolve => frame.addEventListener('load', resolve, { once: true }));
frame.src = `/test-fixtures/reference-exhibits/${name}/index.html`;
document.querySelector('#fixtures').append(frame); await loaded;
const versionTransport = postMessageTransport(frame);
const unsupported = await raw(versionTransport, { xzbt: '5.2', type: 'hello', requestId: 'bad-version', supportedContractMajors: [999] });
assert(unsupported.error?.code === 'UNSUPPORTED_VERSION', 'Version rejection'); versionTransport.close();
await host.connect(postMessageTransport(frame));
assert(host.status === 'connected' && host.sync === 'synchronized', 'Session and snapshot');
const session = host.sessionId;
assert(host.catalog.length > 0, 'Dynamic catalog');
const choose = kind => host.catalog.find(t => t.kind === kind && (kind === 'impulse' || t.writable) && t.requires.length === 0);
const state = choose('state'), range = choose('range'), selection = choose('selection'), impulse = choose('impulse');
assert(state && range && selection && impulse, 'Representative kinds are present');
await host.set(state.id, !host.values.get(state.id));
const rangeValue = host.values.get(range.id) === range.min ? range.max : range.min;
await host.set(range.id, rangeValue); assert(host.values.get(range.id) === rangeValue, 'Range readback');
const option = selection.options.find(o => o.value !== host.values.get(selection.id));
await host.set(selection.id, option.value); assert(host.values.get(selection.id) === option.value, 'Selection readback');
const revision = host.stateRevision; await host.set(selection.id, option.value);
assert(host.stateRevision === revision, 'No-op revision');
await host.invoke(impulse.id, {});
assert(host.eventLog.some(e => e.type === 'action.executed' && e.target === impulse.id), 'Action event delivered');
assert(host.eventLog.some(e => e.type === 'selection.changed'), 'Selection event delivered');
await rejected(() => host.set(range.id, range.max + range.step), 'INVALID_VALUE');
// Bypass the host's local value checks to prove exhibit errors survive the transport.
await rejected(() => host.request('set', { target: range.id, value: range.max + range.step }), 'INVALID_VALUE');
await rejected(() => host.request('invoke', { target: impulse.id, args: { undeclared: true } }), 'INVALID_VALUE');
const withArguments = host.catalog.find(t => t.kind === 'impulse' && t.arguments?.length);
if (withArguments) {
const args = Object.fromEntries(withArguments.arguments.map(a => [a.name, a.min ?? 1]));
await host.invoke(withArguments.id, args);
assert(host.eventLog.some(e => e.target === withArguments.id && Object.keys(e.args ?? {}).length), 'Arguments delivered');
}
const revisionBeforeReconnect = host.stateRevision;
await host.connect(postMessageTransport(frame));
assert(host.sessionId !== session && host.sequence === null, 'Reconnect creates new session and resets sequence');
assert(host.stateRevision >= revisionBeforeReconnect, 'Reconnect preserves exhibit revision');
// A second handshake invalidates the host's session at the real exhibit.
const sideTransport = postMessageTransport(frame);
await raw(sideTransport, { xzbt: '5.2', type: 'hello', requestId: 'replace-session', supportedContractMajors: [5] });
sideTransport.close();
await rejected(() => host.request('state.get'), 'INVALID_SESSION');
assert(host.status === 'invalid session', 'Invalid session is explicit');
await host.connect(postMessageTransport(frame)); assert(host.sync === 'synchronized', 'Recovery after session replacement');
results.textContent += `PASS ${name}: ${host.catalog.length} targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery.\n`;
passed++;
} catch (error) { results.textContent += `FAIL ${name}: ${error.message}\n`; }
finally { host.disconnect(); frame.remove(); }
}
results.textContent += `${passed}/3 real exhibits passed.\n`; run.disabled = false;
});
+219
View File
@@ -0,0 +1,219 @@
> **Local copy notice.** This directory contains locally corrected copies of
> the upstream reference exhibits. The corrections are documented in
> `../PROVENANCE.md`. The text below is the original upstream README; its
> verification claims describe the upstream state, not this corrected copy.
> Re-copying from the upstream source without reapplying corrections will
> reintroduce known defects.
# XZBT Exhibit Contract 5.2 — Reference Exhibits
Three unrelated exhibits and one generic host, all speaking XZBT Exhibit
Contract 5.2. The point of the set is not the exhibits; it is that a single
unmodified host drives all three, and that the contract layer is shared
rather than reimplemented per exhibit.
```
reference-exhibits/
shared/
contract-core.js generic contract engine (no domain knowledge)
host-transport.js same-origin postMessage transport (optional)
exhibit-shell.js generic DOM helpers (optional)
exhibit-shell.css generic page chrome (optional)
aquarium/ exhibit 1 — reef tank
planetarium/ exhibit 2 — sky dome
haunted-house/ exhibit 3 — the one with a real capability lifecycle
host-harness/ a generic host that knows nothing about any of them
tests/ Node test suites + a static server for manual checks
```
## Running it
No build step, no dependencies, no package manager.
**Standalone.** Open any exhibit's `index.html` directly in a browser. It
works from `file://` with the network off. The header reads `standalone` and
nothing is ever posted anywhere.
**With a host.** The harness uses `postMessage` with an origin check, and
`file://` documents report origin `null`, so serve over HTTP:
```
node tests/serve.js
```
Then open `http://localhost:8731/host-harness/index.html`, pick an exhibit,
and press Connect. The harness performs the handshake, reads `describe`, and
builds its entire control surface from the descriptors the exhibit published.
**Tests.**
```
node tests/run-all.js
```
137 checks, no dependencies. See *Verification* below for what they do and
do not cover.
## The layering
Each exhibit is split into four files, and the split is the whole argument:
| File | Knows about | Does not know about |
| --- | --- | --- |
| `exhibit.js` | the domain, the canvas, the native controls | the contract, target IDs, events |
| `contract-adapter.js` | the contract, the target catalog | rendering, DOM |
| `boot.js` | composition only | everything else |
| `index.html` | markup and script order | behaviour |
`exhibit.js` is a complete, working exhibit on its own. Delete
`contract-adapter.js`, `host-transport.js`, and `boot.js`'s last three lines
and you still have a functioning aquarium. That is deliberate: the contract is
a layer added on top of a working exhibit, not a scaffold the exhibit is built
inside of.
The shared `contract-core.js` is domain-free. It knows the *shape* of the
contract — envelopes, target kinds, revisions, sequences, error codes,
capability lifecycle — and nothing about fish, stars, or ghosts. Each adapter
supplies three things and gets a conforming surface:
1. a target catalog (canonical dotted IDs → descriptors)
2. a setter table (target ID → absolute, idempotent setter)
3. an action table (target ID → real impulse implementation)
## The three exhibits
| | Aquarium | Planetarium | Haunted House |
| --- | --- | --- | --- |
| Targets | 12 | 14 | 13 |
| Capabilities | `render` | `render` | `render`, `audio` |
| Notable | step-validated ranges | action with a typed argument | a genuinely gated action |
The Haunted House is the interesting one. Its audio is synthesized with the
Web Audio API, and browsers refuse to start an audio context until the user
has interacted with the page. So `audio` really does begin `available`, really
does pass through `loading` to `ready` on the first gesture, and really does
report `error` if the context cannot be created. The adapter mirrors that real
state into the capability registry; it never invents a transition to make the
contract look busy. `action.moan` and `action.slam-door` declare
`requires: ['audio']` and return `CAPABILITY_UNAVAILABLE` until the engine is
actually ready — while staying discoverable in `describe` the whole time.
## Contract decisions worth stating
**One mutation chokepoint.** Every persistent-state change — native UI,
hotkey, or host `set` — goes through `applyMutation`. One call is one
transaction: commit, increment `stateRevision` at most once, then emit the
resulting events carrying that revision. A host `set` and a slider drag are
the same transaction with a different `source`.
**No silent clamping.** An out-of-range value is rejected with
`INVALID_VALUE`, not quietly pinned to the boundary. A value off the declared
`step` is rejected too. A host that sends nonsense should be told.
**No-ops do not move the revision.** Setters are absolute and idempotent and
report whether anything actually changed. Setting a value to what it already
is returns `changed: false` and leaves `stateRevision` alone, so a host can
poll without inflating history.
**`stateRevision` survives reconnects; `sequence` does not.** A new session
resets the event sequence but deliberately leaves `stateRevision` intact, so a
reconnecting host can tell whether it missed anything.
**Source is assigned at the trusted boundary.** A `source` field inside an
incoming message is ignored. The transport stamps `host`; the UI path stamps
`ui`.
**Event shape is identical on every path.** `correlationId` is always present
(null when the change did not originate from a request) so a host can rely on
one schema regardless of what caused the change.
**Capabilities are honest.** An exhibit that cannot fail declares `ready`
once. An exhibit with a real lifecycle reports it. Nothing fakes a transition.
**Text is data.** Every string that reaches the DOM goes through
`textContent`. Nothing in this project assigns `innerHTML`, so a string
arriving from a host or a scenario can never become markup or script.
## Verification
### Automated — `node tests/run-all.js`
**`conformance.test.js` (126 checks).** Loads the real `contract-core.js` and
the real `contract-adapter.js` from each exhibit into a sandbox, with a small
stub standing in for the exhibit's own services. The adapter under test is the
genuine article; only the domain object behind it is a stub. Covers handshake
and session validation, `describe` shape, canonical target IDs, `state.get`
filtering, mutation semantics (commit, revision, no-op, absolute, rejection),
action semantics, event envelopes and monotonic sequence, session reset,
source trust, UI/host parity, malformed input, the capability lifecycle, and
action arguments. A final cross-exhibit group runs one generic request
sequence against all three.
**`transport.test.js` (11 checks).** Covers the postMessage layer against a
fake window: origin and source validation, version filtering, and — the reason
the file exists — that events are *pushed* to a connected host, not just
responses.
### Manual — browser
Verified in a real browser against a local server:
- All three exhibits render (canvas confirmed painted, not blank) and load
with a clean console.
- Every native control on all three exhibits was exercised; each routes
through the canonical path and produces the expected event with
`source: 'ui'`.
- The generic host connected to all three exhibits unmodified, built its UI
from their descriptors, and drove them: host `set` → exhibit commits → event
pushed back → host revision, sequence, and readout all update.
- The Haunted House capability lifecycle was observed end to end from a real
button click: `available → loading → ready`, with two
`capability.changed` events, after which the gated action succeeded.
- Standalone: each exhibit opened from `file://` with the server stopped.
Renders, contract works, native UI works, and
`performance.getEntriesByType('resource')` shows zero external resources.
### Not covered
- No automated browser tests. Rendering and the browser's audio policy are
verified by hand, as above.
- The exhibits are reference implementations, not production art. The
simulation is simple by design; the contract is the subject.
## Bugs found and fixed during verification
Recorded because they are the kind that pass a unit suite and fail a user:
1. **Target IDs were not canonical.** `action.cleanGlass`,
`telemetry.fishCount`, `sky.magnitudeLimit` and others used camelCase
segments. The catalog validator rejected them at construction. All IDs are
now lowercase-hyphenated (`action.clean-glass`, `telemetry.fish-count`,
`sky.magnitude-limit`).
2. **The transport never forwarded events.** It sent responses only, so a host
could issue a `set`, receive an acknowledgement, and never learn that
anything had changed. The exhibit looked correct in isolation and the
conformance suite passed; only the host/exhibit link was broken. The
transport now forwards the core's events to a connected host, and
`transport.test.js` pins it down.
3. **The harness raced its own navigation.** `connect()` called
`disconnect()` first, which queued an `about:blank` load that then beat the
real navigation, leaving an empty frame.
4. **The harness page never loaded `exhibit-shell.js`.** `harness.js` uses
`XZBTShell` for element lookup, so it threw on load and the page was inert.
5. **`correlationId` was conditionally present.** It was omitted for
UI-originated changes and included for host-originated ones, so the event
shape differed by path. It is now always present, null when not applicable.
## Adding a fourth exhibit
1. Write `exhibit.js` — a working exhibit with absolute, idempotent setters
that return `{ changed }`, a `read(targetId)`, and real actions. No
contract awareness.
2. Write `contract-adapter.js` — declare the catalog, bind setters, readers,
and actions, and route the UI through `core.applyMutation` /
`core.invokeAction`.
3. Write `boot.js` and `index.html` following an existing exhibit.
4. Add the exhibit to `EXHIBITS` in `tests/conformance.test.js` and to
`ADAPTER_EXPORTS`. The shared conformance group then runs against it
automatically.
5. Add it to the harness dropdown. No harness code changes — that is the test.
@@ -0,0 +1,54 @@
/*
* Aquarium — composition root.
*
* The last file to load, and the only one that knows about all the others.
* It builds the exhibit, builds the UI, then hands both to the contract
* adapter. If the adapter were removed, the first two lines would still
* produce a complete, working aquarium.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var announcer = new Shell.Announcer({
node: Shell.el('announcement'),
idleText: 'Tank settled \u2014 awaiting instruction.',
maxLength: 120
});
var exhibit = new window.AquariumExhibit({
canvas: Shell.el('tank-canvas'),
announcer: announcer
});
var ui = new window.AquariumUI(exhibit, announcer);
/* Contract layer. Optional by construction: everything above already
* works without it. */
var core = window.AquariumContract.create(exhibit, ui);
/* Transport layer. Also optional: with no host attached, nothing here
* ever fires and the exhibit is unchanged. */
var transport = new window.XZBTHostTransport({
core: core,
onMessage: function (request, response) {
if (response.type === 'hello.result') {
Shell.setText(Shell.el('contract-status'), 'host attached');
}
}
});
exhibit.start();
window.addEventListener('resize', function () { exhibit.resize(); });
/* Exposed for the conformance harness and for manual console poking. */
window.__exhibit = {
core: core,
exhibit: exhibit,
ui: ui,
transport: transport,
announcer: announcer
};
})();
@@ -0,0 +1,217 @@
/*
* Aquarium — XZBT Exhibit Contract 5.2 adapter.
*
* This file is the only place in the exhibit that knows the contract exists.
* It does three things and nothing else:
*
* 1. declares the public target catalog against the exhibit's real model
* 2. binds each target to a real exhibit service (setter / reader / action)
* 3. routes the native UI through the canonical mutation path
*
* It holds no state of its own. Every value it reports is read live from the
* exhibit, so there is no shadow copy that can drift (Authoring Guide §W).
*/
(function () {
'use strict';
var Core = window.XZBTContractCore;
var IDENTITY = {
product: 'Aquarium',
version: '1.0.0',
build: 'reference-exhibit'
};
/* ------------------------------------------------------------------ *
* Public target catalog.
*
* IDs are canonical and domain-shaped. They are not DOM ids, CSS
* selectors, or widget names (Authoring Guide §W).
* ------------------------------------------------------------------ */
var DESCRIPTORS = [
{
id: 'tank.species',
kind: 'selection',
label: 'Species',
description: 'The species currently stocked in the tank.',
options: [
{ value: 'clownfish', label: 'Clownfish' },
{ value: 'blue-tang', label: 'Blue Tang' },
{ value: 'neon-tetra', label: 'Neon Tetra' },
{ value: 'angelfish', label: 'Angelfish' },
{ value: 'guppy', label: 'Guppy' }
],
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.population',
kind: 'range',
label: 'Population',
description: 'Number of fish in the tank.',
min: 1, max: 40, step: 1, unit: 'fish',
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.lighting',
kind: 'selection',
label: 'Lighting',
description: 'Lighting programme for the tank.',
options: [
{ value: 'daylight', label: 'Daylight' },
{ value: 'twilight', label: 'Twilight' },
{ value: 'moonlight', label: 'Moonlight' },
{ value: 'blackwater', label: 'Blackwater' }
],
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.current',
kind: 'range',
label: 'Water current',
description: 'Strength of the circulation pump, 0 (still) to 1 (strong).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.temperature',
kind: 'range',
label: 'Temperature',
description: 'Heater setting, 0 (18 C) to 1 (28 C).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.paused',
kind: 'state',
valueType: 'boolean',
label: 'Paused',
description: 'Whether the simulation is frozen.',
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'action.feed',
kind: 'impulse',
label: 'Feed',
description: 'Scatter food across the surface.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.startle',
kind: 'impulse',
label: 'Startle',
description: 'Send a pressure wave through the tank.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.clean-glass',
kind: 'impulse',
label: 'Clean glass',
description: 'Run the glass-cleaning cycle.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'telemetry.fish-count',
kind: 'state',
valueType: 'number',
label: 'Fish count',
description: 'Live number of fish currently rendered.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.food-level',
kind: 'state',
valueType: 'number',
label: 'Food level',
description: 'Remaining food in the water column, 0 to 1.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.water-temp',
kind: 'state',
valueType: 'number',
unit: 'C',
label: 'Water temperature',
description: 'Actual water temperature in degrees Celsius.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
}
];
/* ------------------------------------------------------------------ *
* Wiring
* ------------------------------------------------------------------ */
function createAquariumContract(exhibit, ui) {
var catalog = new Core.Catalog(DESCRIPTORS);
/* The exhibit has no capability that can fail: it is pure canvas and
* needs no device, no network, and no permission. Reporting `ready`
* once is honest; inventing loading/error transitions would not be
* (Authoring Guide §M). */
var capabilities = new Core.CapabilityRegistry();
capabilities.declare('render', 'ready');
var setters = {
'tank.species': function (v) { return exhibit.setSpecies(v); },
'tank.population': function (v) { return exhibit.setPopulation(v); },
'tank.lighting': function (v) { return exhibit.setLighting(v); },
'tank.current': function (v) { return exhibit.setCurrent(v); },
'tank.temperature': function (v) { return exhibit.setTemperature(v); },
'tank.paused': function (v) { return exhibit.setPaused(v); }
};
var readers = {};
var ids = catalog.ids();
for (var i = 0; i < ids.length; i++) {
(function (id) {
readers[id] = function () { return exhibit.read(id); };
})(ids[i]);
}
var actions = {
'action.feed': function () { return exhibit.feed(); },
'action.startle': function () { return exhibit.startle(); },
'action.clean-glass': function () { return exhibit.cleanGlass(); }
};
var core = new Core.ContractCore({
identity: IDENTITY,
catalog: catalog,
capabilities: capabilities,
setters: setters,
readers: readers,
actions: actions
});
/* Route the native UI through the canonical path. From here on, a
* slider drag and a host `set` are the same transaction, and both are
* reported with the same source semantics (Authoring Guide §H). */
ui.dispatchOverride = function (targetId, value) {
core.applyMutation(targetId, value, 'ui');
ui.sync();
};
ui.invokeOverride = function (targetId) {
core.invokeAction(targetId, {}, 'ui');
ui.sync();
};
/* Keep the controls honest when the exhibit changes for any reason. */
exhibit.onChange = function () { ui.sync(); };
return core;
}
window.AquariumContract = { create: createAquariumContract, IDENTITY: IDENTITY };
})();
@@ -0,0 +1,694 @@
/*
* Aquarium — exhibit behaviour.
*
* This file is the exhibit. It owns the tank model, the simulation, the
* canvas rendering, and the native controls. It has no contract awareness
* whatsoever: it does not know what a target ID is, it never emits an event,
* and it would work identically if the contract layer were deleted.
*
* That is deliberate. The contract adapter is a layer added on top of a
* working exhibit (Authoring Guide §X), not a scaffold the exhibit is built
* inside of.
*
* The one thing this file does provide for the layer above it is a set of
* absolute, idempotent setters and real actions. Those are the exhibit's own
* services — the same functions the native UI calls — so UI-originated and
* host-originated changes share exactly one code path (Authoring Guide §H).
*/
(function () {
'use strict';
var TAU = Math.PI * 2;
var SPECIES = [
{ id: 'clownfish', label: 'Clownfish', color: '#ff9a3c', size: 9, speed: 1.15, schooling: 0.35 },
{ id: 'blue-tang', label: 'Blue Tang', color: '#3f8cff', size: 12, speed: 0.95, schooling: 0.55 },
{ id: 'neon-tetra', label: 'Neon Tetra', color: '#4fe3ff', size: 6, speed: 1.45, schooling: 0.85 },
{ id: 'angelfish', label: 'Angelfish', color: '#f2e06a', size: 14, speed: 0.7, schooling: 0.2 },
{ id: 'guppy', label: 'Guppy', color: '#ff6fae', size: 7, speed: 1.3, schooling: 0.45 }
];
var LIGHTING = [
{ id: 'daylight', label: 'Daylight', tint: '#bfe9ff', ambient: 1.0, surface: 0.85 },
{ id: 'twilight', label: 'Twilight', tint: '#6f7fd0', ambient: 0.62, surface: 0.5 },
{ id: 'moonlight', label: 'Moonlight', tint: '#3d5a9c', ambient: 0.34, surface: 0.28 },
{ id: 'blackwater', label: 'Blackwater', tint: '#2a1f3d', ambient: 0.18, surface: 0.12 }
];
var FEED_AMOUNT = 0.22;
var FEED_DECAY_PER_SECOND = 0.035;
var MAX_FISH = 40;
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
function findById(list, id) {
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
/* ------------------------------------------------------------------ *
* Tank model
* ------------------------------------------------------------------ */
function Tank() {
this.species = 'clownfish';
this.population = 12;
this.lighting = 'daylight';
this.current = 0.35;
this.temperature = 0.5;
this.foodLevel = 0.0;
this.paused = false;
this.fish = [];
this.bubbles = [];
this.plants = [];
this.time = 0;
this._spawnAll();
this._seedScenery();
}
Tank.prototype._spawnAll = function () {
this.fish = [];
for (var i = 0; i < this.population; i++) this.fish.push(this._makeFish(i));
};
Tank.prototype._makeFish = function (index) {
var species = findById(SPECIES, this.species);
var lane = (index % 5) / 5;
return {
x: Math.random(),
y: 0.18 + lane * 0.62 + (Math.random() - 0.5) * 0.08,
vx: (Math.random() < 0.5 ? -1 : 1) * (0.02 + Math.random() * 0.03),
vy: (Math.random() - 0.5) * 0.012,
phase: Math.random() * TAU,
wobble: 0.6 + Math.random() * 0.8,
size: species.size * (0.8 + Math.random() * 0.45),
energy: 0.5 + Math.random() * 0.5
};
};
Tank.prototype._seedScenery = function () {
this.plants = [];
for (var i = 0; i < 7; i++) {
this.plants.push({
x: 0.06 + i * 0.14 + (Math.random() - 0.5) * 0.05,
height: 0.18 + Math.random() * 0.3,
phase: Math.random() * TAU,
blades: 3 + Math.floor(Math.random() * 3)
});
}
this.bubbles = [];
for (var b = 0; b < 26; b++) {
this.bubbles.push({
x: Math.random(),
y: Math.random(),
r: 0.002 + Math.random() * 0.006,
speed: 0.02 + Math.random() * 0.05,
drift: Math.random() * TAU
});
}
};
/* ------------------------------------------------------------------ *
* Simulation
* ------------------------------------------------------------------ */
Tank.prototype.step = function (dt) {
if (this.paused) return;
this.time += dt;
var species = findById(SPECIES, this.species);
var light = findById(LIGHTING, this.lighting);
/* Food decays on its own, so a feed is a real, observable event rather
* than a flag that stays set forever. */
if (this.foodLevel > 0) {
this.foodLevel = Math.max(0, this.foodLevel - FEED_DECAY_PER_SECOND * dt);
}
var currentForce = (this.current - 0.5) * 0.09;
var tempFactor = 0.6 + this.temperature * 0.9;
var hunger = this.foodLevel;
for (var i = 0; i < this.fish.length; i++) {
var f = this.fish[i];
f.phase += dt * f.wobble * 2.2;
/* Schooling: drift toward the shoal's average position. */
var avgX = 0, avgY = 0;
for (var j = 0; j < this.fish.length; j++) {
avgX += this.fish[j].x;
avgY += this.fish[j].y;
}
avgX /= this.fish.length;
avgY /= this.fish.length;
var pull = species.schooling * 0.35;
f.vx += (avgX - f.x) * pull * dt;
f.vy += (avgY - f.y) * pull * dt;
/* Feeding: food sinks from the surface, so fish rise toward it. */
if (hunger > 0.01) {
f.vy -= hunger * 0.5 * dt;
f.energy = clamp(f.energy + hunger * dt * 0.6, 0, 1);
}
/* Water current pushes horizontally. */
f.vx += currentForce * dt;
/* Temperature sets overall activity. */
var speed = species.speed * tempFactor * (0.7 + f.energy * 0.6);
f.vx = clamp(f.vx, -0.09 * speed, 0.09 * speed);
f.vy = clamp(f.vy, -0.05 * speed, 0.05 * speed);
f.x += f.vx * dt * 6;
f.y += f.vy * dt * 6;
/* Wander and keep off the glass. */
f.vy += Math.sin(f.phase) * 0.004 * dt;
if (f.x < 0.04) { f.x = 0.04; f.vx = Math.abs(f.vx); }
if (f.x > 0.96) { f.x = 0.96; f.vx = -Math.abs(f.vx); }
if (f.y < 0.1) { f.y = 0.1; f.vy = Math.abs(f.vy); }
if (f.y > 0.9) { f.y = 0.9; f.vy = -Math.abs(f.vy); }
f.energy = clamp(f.energy - dt * 0.02, 0.15, 1);
}
/* Bubbles rise faster in warmer, more turbulent water. */
var bubbleSpeed = 0.5 + this.temperature * 0.8 + Math.abs(this.current - 0.5) * 0.6;
for (var b = 0; b < this.bubbles.length; b++) {
var bubble = this.bubbles[b];
bubble.y -= bubble.speed * bubbleSpeed * dt;
bubble.drift += dt * 1.4;
bubble.x += Math.sin(bubble.drift) * 0.004 * dt * 6;
if (bubble.y < -0.02) {
bubble.y = 1.02;
bubble.x = Math.random();
}
}
/* Light level feeds back into the scene's own ambient value. */
this.ambient = light.ambient;
};
/* ------------------------------------------------------------------ *
* Rendering
* ------------------------------------------------------------------ */
function Renderer(canvas, tank) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.tank = tank;
this.width = 0;
this.height = 0;
this.resize();
}
Renderer.prototype.resize = function () {
var rect = this.canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
this.width = Math.max(1, Math.round(rect.width));
this.height = Math.max(1, Math.round(rect.height));
this.canvas.width = Math.round(this.width * dpr);
this.canvas.height = Math.round(this.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
Renderer.prototype.draw = function () {
var ctx = this.ctx;
var w = this.width;
var h = this.height;
var tank = this.tank;
var light = findById(LIGHTING, tank.lighting);
var species = findById(SPECIES, tank.species);
ctx.clearRect(0, 0, w, h);
/* Water column. */
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, shade(light.tint, 0.55 * light.ambient + 0.12));
grad.addColorStop(0.55, shade(light.tint, 0.3 * light.ambient + 0.06));
grad.addColorStop(1, shade(light.tint, 0.12 * light.ambient + 0.03));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
/* Caustic light shafts from the surface. */
ctx.save();
ctx.globalAlpha = 0.1 * light.surface;
ctx.fillStyle = '#ffffff';
for (var s = 0; s < 6; s++) {
var sx = ((s / 6) + Math.sin(tank.time * 0.25 + s) * 0.03) * w;
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx + w * 0.05, 0);
ctx.lineTo(sx + w * 0.12, h);
ctx.lineTo(sx - w * 0.02, h);
ctx.closePath();
ctx.fill();
}
ctx.restore();
/* Substrate. */
ctx.fillStyle = shade(light.tint, 0.1);
ctx.beginPath();
ctx.moveTo(0, h);
for (var x = 0; x <= w; x += 12) {
var y = h - 26 - Math.sin(x * 0.02) * 6 - Math.sin(x * 0.007) * 9;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
/* Plants. */
for (var p = 0; p < tank.plants.length; p++) {
var plant = tank.plants[p];
var baseX = plant.x * w;
var baseY = h - 24;
var sway = Math.sin(tank.time * 0.9 + plant.phase) * (0.02 + tank.current * 0.05);
ctx.strokeStyle = shade('#2f7d4f', 0.35 + light.ambient * 0.5);
ctx.lineWidth = 2.5;
for (var bl = 0; bl < plant.blades; bl++) {
var offset = (bl - (plant.blades - 1) / 2) * 7;
ctx.beginPath();
ctx.moveTo(baseX + offset, baseY);
ctx.quadraticCurveTo(
baseX + offset + sway * w * 0.5,
baseY - plant.height * h * 0.6,
baseX + offset + sway * w,
baseY - plant.height * h
);
ctx.stroke();
}
}
/* Fish. */
for (var i = 0; i < tank.fish.length; i++) {
var f = tank.fish[i];
var fx = f.x * w;
var fy = f.y * h;
var facing = f.vx >= 0 ? 1 : -1;
var size = f.size * (0.85 + light.ambient * 0.3);
ctx.save();
ctx.translate(fx, fy);
ctx.scale(facing, 1);
/* Tail. */
ctx.fillStyle = shade(species.color, 0.55 + light.ambient * 0.4);
ctx.beginPath();
ctx.moveTo(-size * 0.9, 0);
ctx.lineTo(-size * 1.7, -size * 0.55 + Math.sin(f.phase) * size * 0.18);
ctx.lineTo(-size * 1.7, size * 0.55 + Math.sin(f.phase) * size * 0.18);
ctx.closePath();
ctx.fill();
/* Body. */
ctx.fillStyle = shade(species.color, 0.6 + light.ambient * 0.45);
ctx.beginPath();
ctx.ellipse(0, 0, size, size * 0.52, 0, 0, TAU);
ctx.fill();
/* Eye. */
ctx.fillStyle = 'rgba(10,12,18,0.85)';
ctx.beginPath();
ctx.arc(size * 0.5, -size * 0.1, Math.max(1, size * 0.13), 0, TAU);
ctx.fill();
ctx.restore();
}
/* Bubbles. */
ctx.strokeStyle = 'rgba(255,255,255,' + (0.12 + light.ambient * 0.22) + ')';
ctx.lineWidth = 1;
for (var b = 0; b < tank.bubbles.length; b++) {
var bubble = tank.bubbles[b];
ctx.beginPath();
ctx.arc(bubble.x * w, bubble.y * h, bubble.r * w, 0, TAU);
ctx.stroke();
}
/* Food particles sinking from the surface. */
if (tank.foodLevel > 0.01) {
ctx.fillStyle = 'rgba(226,196,120,' + (0.35 + tank.foodLevel * 0.5) + ')';
for (var k = 0; k < 40; k++) {
var seed = k * 12.9898;
var px = (Math.sin(seed) * 0.5 + 0.5) * w;
var py = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * 0.5 + 0.5) * h;
var sink = (tank.time * 0.06 + (k / 40)) % 1;
ctx.beginPath();
ctx.arc(px, (py * 0.3 + sink * 0.7) * h, 1.6, 0, TAU);
ctx.fill();
}
}
/* Surface line. */
ctx.strokeStyle = 'rgba(255,255,255,' + (0.1 + light.surface * 0.25) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
for (var sx2 = 0; sx2 <= w; sx2 += 8) {
var sy = 8 + Math.sin(sx2 * 0.03 + tank.time * 1.6) * 3;
if (sx2 === 0) ctx.moveTo(sx2, sy); else ctx.lineTo(sx2, sy);
}
ctx.stroke();
if (tank.paused) {
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.font = '600 13px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('PAUSED', w / 2, h / 2);
ctx.textAlign = 'start';
}
};
/** Lighten/darken a hex colour by a 0..1 factor. */
function shade(hex, factor) {
var n = parseInt(hex.slice(1), 16);
var r = (n >> 16) & 255;
var g = (n >> 8) & 255;
var b = n & 255;
var f = clamp(factor, 0, 1.6);
r = Math.round(clamp(r * f, 0, 255));
g = Math.round(clamp(g * f, 0, 255));
b = Math.round(clamp(b * f, 0, 255));
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
/* ------------------------------------------------------------------ *
* Exhibit
* ------------------------------------------------------------------ */
function AquariumExhibit(options) {
this.canvas = options.canvas;
this.tank = new Tank();
this.renderer = new Renderer(this.canvas, this.tank);
this.announcer = options.announcer;
this.onChange = options.onChange || function () {};
this._lastFrame = 0;
this._running = false;
this._boundFrame = this._frame.bind(this);
}
AquariumExhibit.prototype.start = function () {
if (this._running) return;
this._running = true;
this._lastFrame = performance.now();
requestAnimationFrame(this._boundFrame);
};
AquariumExhibit.prototype._frame = function (now) {
if (!this._running) return;
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
this._lastFrame = now;
this.tank.step(dt);
this.renderer.draw();
requestAnimationFrame(this._boundFrame);
};
AquariumExhibit.prototype.resize = function () {
this.renderer.resize();
};
/* ------------------------------------------------------------------ *
* Absolute, idempotent setters.
*
* Each returns { changed: boolean }. Returning changed:false for a no-op
* is what lets the contract layer leave stateRevision untouched, and it is
* also what stops a host-set value from being silently reverted the next
* time a local control is touched (Authoring Guide §W).
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.setSpecies = function (id) {
if (this.tank.species === id) return { changed: false };
this.tank.species = id;
/* Restocking is a real consequence of changing species, so the fish are
* respawned rather than recoloured in place. */
this.tank._spawnAll();
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setPopulation = function (count) {
count = Math.round(count);
if (this.tank.population === count) return { changed: false };
var previous = this.tank.population;
this.tank.population = count;
if (count > previous) {
for (var i = previous; i < count; i++) this.tank.fish.push(this.tank._makeFish(i));
} else {
this.tank.fish.length = count;
}
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setLighting = function (id) {
if (this.tank.lighting === id) return { changed: false };
this.tank.lighting = id;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setCurrent = function (value) {
if (this.tank.current === value) return { changed: false };
this.tank.current = value;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setTemperature = function (value) {
if (this.tank.temperature === value) return { changed: false };
this.tank.temperature = value;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setPaused = function (on) {
if (this.tank.paused === on) return { changed: false };
this.tank.paused = on;
this.onChange();
return { changed: true };
};
/* ------------------------------------------------------------------ *
* Real actions.
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.feed = function () {
this.tank.foodLevel = clamp(this.tank.foodLevel + FEED_AMOUNT, 0, 1);
for (var i = 0; i < this.tank.fish.length; i++) {
this.tank.fish[i].energy = clamp(this.tank.fish[i].energy + 0.25, 0, 1);
}
this.announcer.flash('Feeding — food dispersing through the water column.');
this.onChange();
return { ok: true };
};
AquariumExhibit.prototype.startle = function () {
for (var i = 0; i < this.tank.fish.length; i++) {
var f = this.tank.fish[i];
f.vx += (Math.random() - 0.5) * 0.12;
f.vy += (Math.random() - 0.5) * 0.08;
f.energy = 1;
}
this.announcer.flash('Startle — the shoal scatters.');
this.onChange();
return { ok: true };
};
AquariumExhibit.prototype.cleanGlass = function () {
this.tank.bubbles.length = 0;
for (var b = 0; b < 26; b++) {
this.tank.bubbles.push({
x: Math.random(),
y: Math.random(),
r: 0.002 + Math.random() * 0.006,
speed: 0.02 + Math.random() * 0.05,
drift: Math.random() * TAU
});
}
this.announcer.flash('Glass cleaned — clarity restored.');
this.onChange();
return { ok: true };
};
/* ------------------------------------------------------------------ *
* Reads
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.read = function (targetId) {
var t = this.tank;
switch (targetId) {
case 'tank.species': return t.species;
case 'tank.population': return t.population;
case 'tank.lighting': return t.lighting;
case 'tank.current': return t.current;
case 'tank.temperature': return t.temperature;
case 'tank.paused': return t.paused;
case 'telemetry.fish-count': return t.fish.length;
case 'telemetry.food-level': return Math.round(t.foodLevel * 1000) / 1000;
case 'telemetry.water-temp': return Math.round((18 + t.temperature * 10) * 10) / 10;
default: return null;
}
};
/* ------------------------------------------------------------------ *
* Native UI
*
* Every handler below calls the exhibit's own service method. None of them
* dispatches a synthetic DOM event, and none of them writes state directly
* (Authoring Guide §W).
* ------------------------------------------------------------------ */
function AquariumUI(exhibit, announcer) {
this.exhibit = exhibit;
this.announcer = announcer;
this.nodes = {
species: document.getElementById('species-select'),
population: document.getElementById('population-range'),
populationReadout: document.getElementById('population-readout'),
lighting: document.getElementById('lighting-select'),
current: document.getElementById('current-range'),
currentReadout: document.getElementById('current-readout'),
temperature: document.getElementById('temperature-range'),
temperatureReadout: document.getElementById('temperature-readout'),
pause: document.getElementById('pause-button'),
feed: document.getElementById('feed-button'),
startle: document.getElementById('startle-button'),
clean: document.getElementById('clean-button')
};
this._buildOptions();
this._bind();
this.sync();
}
AquariumUI.prototype._buildOptions = function () {
var speciesSelect = this.nodes.species;
for (var i = 0; i < SPECIES.length; i++) {
var opt = document.createElement('option');
opt.value = SPECIES[i].id;
opt.textContent = SPECIES[i].label;
speciesSelect.appendChild(opt);
}
var lightSelect = this.nodes.lighting;
for (var j = 0; j < LIGHTING.length; j++) {
var lopt = document.createElement('option');
lopt.value = LIGHTING[j].id;
lopt.textContent = LIGHTING[j].label;
lightSelect.appendChild(lopt);
}
};
AquariumUI.prototype._bind = function () {
var self = this;
var ex = this.exhibit;
this.nodes.species.addEventListener('change', function (e) {
self.dispatch('tank.species', e.target.value);
});
this.nodes.population.addEventListener('input', function (e) {
self.dispatch('tank.population', Number(e.target.value));
});
this.nodes.lighting.addEventListener('change', function (e) {
self.dispatch('tank.lighting', e.target.value);
});
this.nodes.current.addEventListener('input', function (e) {
self.dispatch('tank.current', Number(e.target.value));
});
this.nodes.temperature.addEventListener('input', function (e) {
self.dispatch('tank.temperature', Number(e.target.value));
});
this.nodes.pause.addEventListener('click', function () {
self.dispatch('tank.paused', !ex.tank.paused);
});
this.nodes.feed.addEventListener('click', function () {
self.invoke('action.feed');
});
this.nodes.startle.addEventListener('click', function () {
self.invoke('action.startle');
});
this.nodes.clean.addEventListener('click', function () {
self.invoke('action.clean-glass');
});
};
/**
* The UI's route into the canonical path. `dispatch` is installed by the
* contract adapter; until then the UI still works, because the fallback
* calls the exhibit service directly. Either way the exhibit's own setter
* is the only thing that mutates state.
*/
AquariumUI.prototype.dispatch = function (targetId, value) {
if (typeof this.dispatchOverride === 'function') {
this.dispatchOverride(targetId, value);
return;
}
this._fallbackSet(targetId, value);
};
AquariumUI.prototype.invoke = function (targetId) {
if (typeof this.invokeOverride === 'function') {
this.invokeOverride(targetId);
return;
}
this._fallbackInvoke(targetId);
};
AquariumUI.prototype._fallbackSet = function (targetId, value) {
var ex = this.exhibit;
switch (targetId) {
case 'tank.species': ex.setSpecies(value); break;
case 'tank.population': ex.setPopulation(value); break;
case 'tank.lighting': ex.setLighting(value); break;
case 'tank.current': ex.setCurrent(value); break;
case 'tank.temperature': ex.setTemperature(value); break;
case 'tank.paused': ex.setPaused(value); break;
default: break;
}
this.sync();
};
AquariumUI.prototype._fallbackInvoke = function (targetId) {
var ex = this.exhibit;
if (targetId === 'action.feed') ex.feed();
else if (targetId === 'action.startle') ex.startle();
else if (targetId === 'action.clean-glass') ex.cleanGlass();
this.sync();
};
/** Reflect current exhibit state into the controls. */
AquariumUI.prototype.sync = function () {
var t = this.exhibit.tank;
var n = this.nodes;
if (n.species.value !== t.species) n.species.value = t.species;
if (Number(n.population.value) !== t.population) n.population.value = String(t.population);
if (n.lighting.value !== t.lighting) n.lighting.value = t.lighting;
if (Number(n.current.value) !== t.current) n.current.value = String(t.current);
if (Number(n.temperature.value) !== t.temperature) n.temperature.value = String(t.temperature);
n.populationReadout.textContent = t.population + ' fish';
n.currentReadout.textContent = window.XZBTShell.formatPercent(t.current);
n.temperatureReadout.textContent = window.XZBTShell.formatNumber(18 + t.temperature * 10, 1) + ' \u00b0C';
n.pause.setAttribute('aria-pressed', t.paused ? 'true' : 'false');
n.pause.textContent = t.paused ? 'Resume' : 'Pause';
};
/* ------------------------------------------------------------------ *
* Exports
* ------------------------------------------------------------------ */
window.AquariumExhibit = AquariumExhibit;
window.AquariumUI = AquariumUI;
window.AquariumData = { SPECIES: SPECIES, LIGHTING: LIGHTING, MAX_FISH: MAX_FISH };
})();
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aquarium — XZBT Reference Exhibit</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>Aquarium</h1>
<span class="subtitle">Reef tank &mdash; live simulation</span>
<span class="status" id="contract-status">standalone</span>
</header>
<main class="stage">
<canvas id="tank-canvas" aria-label="Aquarium view"></canvas>
</main>
<aside class="panel">
<section>
<h2>Stocking</h2>
<div class="control">
<label for="species-select">Species</label>
<select id="species-select"></select>
</div>
<div class="control">
<label for="population-range">
<span>Population</span>
<span class="readout" id="population-readout">12 fish</span>
</label>
<input type="range" id="population-range" min="1" max="40" step="1" value="12">
</div>
</section>
<section>
<h2>Environment</h2>
<div class="control">
<label for="lighting-select">Lighting</label>
<select id="lighting-select"></select>
</div>
<div class="control">
<label for="current-range">
<span>Water current</span>
<span class="readout" id="current-readout">35%</span>
</label>
<input type="range" id="current-range" min="0" max="1" step="0.01" value="0.35">
</div>
<div class="control">
<label for="temperature-range">
<span>Temperature</span>
<span class="readout" id="temperature-readout">23.0 &deg;C</span>
</label>
<input type="range" id="temperature-range" min="0" max="1" step="0.01" value="0.5">
</div>
</section>
<section>
<h2>Actions</h2>
<div class="button-row">
<button id="feed-button" type="button">Feed</button>
<button id="startle-button" type="button">Startle</button>
<button id="clean-button" type="button">Clean glass</button>
</div>
<div class="toggle-row" style="margin-top:12px">
<span>Simulation</span>
<button id="pause-button" type="button" aria-pressed="false">Pause</button>
</div>
</section>
</aside>
<footer class="exhibit-footer">
<span class="announcement-label">Tank</span>
<span class="announcement-text is-idle" id="announcement">Tank settled &mdash; awaiting instruction.</span>
</footer>
</div>
<script src="../shared/contract-core.js"></script>
<script src="../shared/exhibit-shell.js"></script>
<script src="../shared/host-transport.js"></script>
<script src="exhibit.js"></script>
<script src="contract-adapter.js"></script>
<script src="boot.js"></script>
</body>
</html>
@@ -0,0 +1,54 @@
/* Aquarium — domain styling only. Layout and controls come from the shell. */
body {
background: #071018;
color: #dff1ff;
}
.exhibit-header {
background: linear-gradient(90deg, rgba(30, 90, 130, 0.35), rgba(255, 255, 255, 0.03));
border-color: rgba(120, 200, 255, 0.22);
}
.exhibit-header h1 { color: #bfe9ff; }
.stage {
border-color: rgba(120, 200, 255, 0.25);
box-shadow: inset 0 0 60px rgba(0, 40, 70, 0.8);
}
.panel {
background: rgba(20, 60, 90, 0.22);
border-color: rgba(120, 200, 255, 0.18);
}
.panel h2 { color: #8fd4ff; }
input[type="range"] { accent-color: #4fc3ff; }
button {
background: rgba(79, 195, 255, 0.12);
border-color: rgba(120, 200, 255, 0.35);
color: #dff1ff;
}
button:hover { background: rgba(79, 195, 255, 0.24); }
button[aria-pressed="true"] {
background: rgba(79, 195, 255, 0.4);
border-color: rgba(160, 225, 255, 0.7);
}
select {
background: rgba(4, 24, 38, 0.85);
border-color: rgba(120, 200, 255, 0.3);
}
.exhibit-footer {
background: rgba(20, 60, 90, 0.22);
border-color: rgba(120, 200, 255, 0.18);
}
.exhibit-footer .announcement-label { color: #8fd4ff; opacity: 0.7; }
.exhibit-header .status { color: #8fd4ff; }
@@ -0,0 +1,44 @@
/*
* Haunted House — composition root. Last file to load.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var announcer = new Shell.Announcer({
node: Shell.el('announcement'),
idleText: 'The house is quiet \u2014 awaiting instruction.',
maxLength: 120
});
var exhibit = new window.HauntedHouseExhibit({
canvas: Shell.el('room-canvas'),
announcer: announcer
});
var ui = new window.HauntedHouseUI(exhibit, announcer);
var core = window.HauntedHouseContract.create(exhibit, ui);
var transport = new window.XZBTHostTransport({
core: core,
onMessage: function (request, response) {
if (response.type === 'hello.result') {
Shell.setText(Shell.el('contract-status'), 'host attached');
}
}
});
exhibit.start();
window.addEventListener('resize', function () { exhibit.resize(); });
window.__exhibit = {
core: core,
exhibit: exhibit,
ui: ui,
transport: transport,
announcer: announcer
};
})();
@@ -0,0 +1,218 @@
/*
* Haunted House — XZBT Exhibit Contract 5.2 adapter.
*
* This adapter is the one that has to be honest about capabilities. The
* exhibit's audio engine cannot start until the browser has seen a user
* gesture, so `audio` is declared `available` at boot and moves to
* `loading` then `ready` when it really does. The adapter mirrors the
* exhibit's real state into the capability registry; it never invents a
* transition (Authoring Guide §M).
*
* The audio-dependent actions are gated on that capability, so invoking
* `action.moan` before the engine is ready returns CAPABILITY_UNAVAILABLE
* rather than silently doing nothing.
*/
(function () {
'use strict';
var Core = window.XZBTContractCore;
var IDENTITY = {
product: 'Haunted House',
version: '1.0.0',
build: 'reference-exhibit'
};
var DESCRIPTORS = [
{
id: 'room.current',
kind: 'selection',
label: 'Room',
description: 'Room the visitor is standing in.',
options: [
{ value: 'foyer', label: 'Foyer' },
{ value: 'library', label: 'Library' },
{ value: 'cellar', label: 'Cellar' },
{ value: 'attic', label: 'Attic' },
{ value: 'nursery', label: 'Nursery' }
],
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.spirit',
kind: 'selection',
label: 'Spirit',
description: 'Which presence is currently resident.',
options: [
{ value: 'restless', label: 'Restless' },
{ value: 'vengeful', label: 'Vengeful' },
{ value: 'mourning', label: 'Mourning' },
{ value: 'mischievous', label: 'Mischievous' }
],
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.haunt-level',
kind: 'range',
label: 'Haunt level',
description: 'Intensity of the haunting, 0 (dormant) to 1 (manifest).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.fog-density',
kind: 'range',
label: 'Fog density',
description: 'Density of the ground fog.',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.candlelight',
kind: 'range',
label: 'Candlelight',
description: 'Strength of the candle pool of light.',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.audio-enabled',
kind: 'state',
valueType: 'boolean',
label: 'Audio enabled',
description: 'Whether the house is permitted to make sound.',
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.paused',
kind: 'state',
valueType: 'boolean',
label: 'Held',
description: 'Whether the haunt simulation is frozen.',
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'action.moan',
kind: 'impulse',
label: 'Moan',
description: 'Emit a long descending moan.',
readable: false, writable: false, restorable: false,
category: 'action', requires: ['audio']
},
{
id: 'action.slam-door',
kind: 'impulse',
label: 'Slam door',
description: 'Slam a door somewhere in the house.',
readable: false, writable: false, restorable: false,
category: 'action', requires: ['audio']
},
{
id: 'action.manifest',
kind: 'impulse',
label: 'Manifest',
description: 'Bring the apparition to full manifestation.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.calm',
kind: 'impulse',
label: 'Calm',
description: 'Settle the house back to dormancy.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'telemetry.audio-state',
kind: 'state',
valueType: 'string',
label: 'Audio engine state',
description: 'Live state of the Web Audio engine.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.room-damp',
kind: 'state',
valueType: 'number',
label: 'Room damping',
description: 'How strongly the current room suppresses the haunt.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
}
];
function createHauntedHouseContract(exhibit, ui) {
var catalog = new Core.Catalog(DESCRIPTORS);
var capabilities = new Core.CapabilityRegistry();
capabilities.declare('render', 'ready');
/* Honest initial state: the engine exists but the browser will not let
* it start until a gesture arrives. */
capabilities.declare('audio', 'available');
var setters = {
'room.current': function (v) { return exhibit.setRoom(v); },
'room.spirit': function (v) { return exhibit.setSpirit(v); },
'room.haunt-level': function (v) { return exhibit.setHauntLevel(v); },
'room.fog-density': function (v) { return exhibit.setFogDensity(v); },
'room.candlelight': function (v) { return exhibit.setCandlelight(v); },
'room.audio-enabled': function (v) { return exhibit.setAudioEnabled(v); },
'room.paused': function (v) { return exhibit.setPaused(v); }
};
var readers = {};
var ids = catalog.ids();
for (var i = 0; i < ids.length; i++) {
(function (id) {
readers[id] = function () { return exhibit.read(id); };
})(ids[i]);
}
var actions = {
'action.moan': function () { return exhibit.moan(); },
'action.slam-door': function () { return exhibit.slamDoor(); },
'action.manifest': function () { return exhibit.manifest(); },
'action.calm': function () { return exhibit.calm(); }
};
var core = new Core.ContractCore({
identity: IDENTITY,
catalog: catalog,
capabilities: capabilities,
setters: setters,
readers: readers,
actions: actions
});
/* Mirror the exhibit's real audio lifecycle into the contract. This is
* a genuine state change driven by the browser, not a scripted one. */
exhibit.audio.onStateChange = function (state) {
core.setCapabilityState('audio', state);
ui.sync();
};
ui.dispatchOverride = function (targetId, value) {
core.applyMutation(targetId, value, 'ui');
ui.sync();
};
ui.invokeOverride = function (targetId) {
core.invokeAction(targetId, {}, 'ui');
ui.sync();
};
exhibit.onChange = function () { ui.sync(); };
return core;
}
window.HauntedHouseContract = { create: createHauntedHouseContract, IDENTITY: IDENTITY };
})();
@@ -0,0 +1,764 @@
/*
* Haunted House — exhibit behaviour.
*
* Owns the room model, the haunt simulation, the canvas rendering, and the
* native controls. No contract awareness.
*
* This exhibit is the one that exercises the capability lifecycle honestly:
* its audio is synthesized with the Web Audio API, which browsers refuse to
* start until the user has interacted with the page. So `audio` really does
* begin `available`, really does go `loading` and then `ready` on the first
* gesture, and really does report `error` if the context cannot be created.
* Nothing here fakes a transition to make the contract look busy.
*/
(function () {
'use strict';
var TAU = Math.PI * 2;
var ROOMS = [
{ id: 'foyer', label: 'Foyer', width: 1.0, damp: 0.35, hue: '#6b5a3e' },
{ id: 'library', label: 'Library', width: 0.9, damp: 0.6, hue: '#5a4630' },
{ id: 'cellar', label: 'Cellar', width: 0.8, damp: 0.85, hue: '#3a3a44' },
{ id: 'attic', label: 'Attic', width: 0.7, damp: 0.5, hue: '#4a4038' },
{ id: 'nursery', label: 'Nursery', width: 0.75, damp: 0.4, hue: '#5c4a5a' }
];
var SPIRITS = [
{ id: 'restless', label: 'Restless', energy: 0.35, color: '#9fd8ff' },
{ id: 'vengeful', label: 'Vengeful', energy: 0.75, color: '#ff8a6a' },
{ id: 'mourning', label: 'Mourning', energy: 0.5, color: '#b9a6ff' },
{ id: 'mischievous', label: 'Mischievous', energy: 0.6, color: '#8affc4' }
];
var MAX_FOG = 1.0;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function findById(list, id) {
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
return null;
}
function hash(n) {
var x = Math.sin(n * 91.7 + 47.3) * 43758.5453;
return x - Math.floor(x);
}
/* ------------------------------------------------------------------ *
* Room model
* ------------------------------------------------------------------ */
function House() {
this.room = 'foyer';
this.spirit = 'restless';
this.hauntLevel = 0.3;
this.fogDensity = 0.25;
this.candlelight = 0.55;
this.audioEnabled = true;
this.paused = false;
this.time = 0;
this.embers = [];
this.dust = [];
this._seed();
}
House.prototype._seed = function () {
this.embers = [];
for (var i = 0; i < 18; i++) {
this.embers.push({
x: hash(i * 1.3),
y: hash(i * 2.7),
vy: -(0.01 + hash(i * 3.9) * 0.03),
phase: hash(i * 5.1) * TAU
});
}
this.dust = [];
for (var d = 0; d < 60; d++) {
this.dust.push({
x: hash(d * 7.7),
y: hash(d * 8.3),
vx: (hash(d * 9.1) - 0.5) * 0.01,
vy: (hash(d * 11.3) - 0.5) * 0.006,
r: 0.4 + hash(d * 13.7) * 1.2
});
}
}
House.prototype.step = function (dt) {
if (this.paused) return;
this.time += dt;
var spirit = findById(SPIRITS, this.spirit);
var room = findById(ROOMS, this.room);
/* The haunt level drifts toward the spirit's natural energy, damped by
* the room. This is what makes the room and spirit selections matter
* rather than being cosmetic. */
var target = spirit.energy * (1 - room.damp * 0.5);
this.hauntLevel += (target - this.hauntLevel) * dt * 0.35;
for (var i = 0; i < this.embers.length; i++) {
var e = this.embers[i];
e.y += e.vy * dt * (0.6 + this.hauntLevel);
e.phase += dt * 2.2;
if (e.y < -0.05) { e.y = 1.05; e.x = hash(i + this.time); }
}
for (var d = 0; d < this.dust.length; d++) {
var p = this.dust[d];
p.x += p.vx * dt * (0.5 + this.hauntLevel * 2);
p.y += p.vy * dt;
if (p.x < 0) p.x = 1;
if (p.x > 1) p.x = 0;
if (p.y < 0) p.y = 1;
if (p.y > 1) p.y = 0;
}
};
/* ------------------------------------------------------------------ *
* Audio service
*
* A real Web Audio drone, and a real capability lifecycle. The context is
* created lazily on the first user gesture because browsers require it.
* ------------------------------------------------------------------ */
function AudioService() {
this.ctx = null;
this.master = null;
this.oscillators = [];
this.state = 'available';
this.onStateChange = function () {};
}
AudioService.prototype._setState = function (state) {
if (this.state === state) return;
this.state = state;
this.onStateChange(state);
};
/** Called from a real user gesture. */
AudioService.prototype.prepare = function () {
if (this.ctx) {
if (this.ctx.state === 'suspended') this.ctx.resume();
this._setState('ready');
return true;
}
this._setState('loading');
try {
var Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) {
this._setState('error');
return false;
}
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = 0.0;
this.master.connect(this.ctx.destination);
this._buildDrone();
this._setState('ready');
return true;
} catch (err) {
this.ctx = null;
this._setState('error');
return false;
}
};
AudioService.prototype._buildDrone = function () {
var ctx = this.ctx;
var freqs = [55, 82.5, 110, 164.8];
for (var i = 0; i < freqs.length; i++) {
var osc = ctx.createOscillator();
osc.type = i % 2 === 0 ? 'sine' : 'triangle';
osc.frequency.value = freqs[i];
var gain = ctx.createGain();
gain.gain.value = 0.12 / (i + 1);
osc.connect(gain);
gain.connect(this.master);
osc.start();
this.oscillators.push({ osc: osc, gain: gain, base: freqs[i] });
}
};
/** Absolute, idempotent: sets the drone's intensity, not a delta. */
AudioService.prototype.setIntensity = function (value) {
if (!this.ctx || !this.master) return;
var now = this.ctx.currentTime;
this.master.gain.cancelScheduledValues(now);
this.master.gain.setTargetAtTime(value * 0.5, now, 0.4);
for (var i = 0; i < this.oscillators.length; i++) {
var o = this.oscillators[i];
o.osc.frequency.setTargetAtTime(o.base * (1 + value * 0.35), now, 0.6);
}
};
AudioService.prototype.moan = function () {
if (!this.ctx || !this.master) return false;
var ctx = this.ctx;
var now = ctx.currentTime;
var osc = ctx.createOscillator();
var gain = ctx.createGain();
var filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 320;
filter.Q.value = 6;
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(180, now);
osc.frequency.exponentialRampToValueAtTime(90, now + 1.6);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.22, now + 0.25);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 1.8);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.master);
osc.start(now);
osc.stop(now + 1.9);
return true;
};
AudioService.prototype.slam = function () {
if (!this.ctx || !this.master) return false;
var ctx = this.ctx;
var now = ctx.currentTime;
var bufferSize = Math.floor(ctx.sampleRate * 0.5);
var buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
var data = buffer.getChannelData(0);
for (var i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 3);
}
var src = ctx.createBufferSource();
src.buffer = buffer;
var filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 420;
var gain = ctx.createGain();
gain.gain.value = 0.5;
src.connect(filter);
filter.connect(gain);
gain.connect(this.master);
src.start(now);
return true;
};
AudioService.prototype.stop = function () {
if (!this.ctx) return;
for (var i = 0; i < this.oscillators.length; i++) {
try { this.oscillators[i].osc.stop(); } catch (e) { /* already stopped */ }
}
this.oscillators = [];
try { this.ctx.close(); } catch (e) { /* already closed */ }
this.ctx = null;
this.master = null;
this._setState('available');
};
/* ------------------------------------------------------------------ *
* Rendering
* ------------------------------------------------------------------ */
function Renderer(canvas, house) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.house = house;
this.width = 0;
this.height = 0;
this.resize();
}
Renderer.prototype.resize = function () {
var rect = this.canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
this.width = Math.max(1, Math.round(rect.width));
this.height = Math.max(1, Math.round(rect.height));
this.canvas.width = Math.round(this.width * dpr);
this.canvas.height = Math.round(this.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
Renderer.prototype.draw = function () {
var ctx = this.ctx;
var w = this.width;
var h = this.height;
var house = this.house;
var room = findById(ROOMS, house.room);
var spirit = findById(SPIRITS, house.spirit);
var light = house.candlelight;
ctx.clearRect(0, 0, w, h);
/* Room shell. */
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, shade(room.hue, 0.35 + light * 0.5));
grad.addColorStop(1, shade(room.hue, 0.12 + light * 0.2));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
/* Back wall panelling. */
ctx.strokeStyle = 'rgba(0,0,0,0.28)';
ctx.lineWidth = 1;
for (var x = 0; x < w; x += 46) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
/* Window, brighter with candlelight. */
var winW = w * 0.22;
var winH = h * 0.3;
var winX = w * 0.62;
var winY = h * 0.16;
ctx.fillStyle = 'rgba(120,150,190,' + (0.1 + light * 0.35) + ')';
ctx.fillRect(winX, winY, winW, winH);
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 3;
ctx.strokeRect(winX, winY, winW, winH);
ctx.beginPath();
ctx.moveTo(winX + winW / 2, winY);
ctx.lineTo(winX + winW / 2, winY + winH);
ctx.moveTo(winX, winY + winH / 2);
ctx.lineTo(winX + winW, winY + winH / 2);
ctx.stroke();
/* Floor. */
ctx.fillStyle = shade(room.hue, 0.08 + light * 0.12);
ctx.beginPath();
ctx.moveTo(0, h);
ctx.lineTo(w * 0.12, h * 0.72);
ctx.lineTo(w * 0.88, h * 0.72);
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
/* Candle pool of light. */
var pool = ctx.createRadialGradient(w * 0.3, h * 0.62, 0, w * 0.3, h * 0.62, Math.min(w, h) * 0.55);
pool.addColorStop(0, 'rgba(255,196,110,' + (0.28 * light) + ')');
pool.addColorStop(1, 'rgba(255,196,110,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, w, h);
/* Fog. */
if (house.fogDensity > 0.01) {
ctx.save();
ctx.globalAlpha = clamp(house.fogDensity * 0.5, 0, 0.6);
for (var f = 0; f < 5; f++) {
var fy = h * (0.55 + f * 0.09);
var fx = ((house.time * (0.01 + f * 0.004)) % 1) * w;
var fogGrad = ctx.createRadialGradient(fx, fy, 0, fx, fy, w * 0.4);
fogGrad.addColorStop(0, 'rgba(190,200,215,0.5)');
fogGrad.addColorStop(1, 'rgba(190,200,215,0)');
ctx.fillStyle = fogGrad;
ctx.fillRect(0, fy - h * 0.2, w, h * 0.4);
}
ctx.restore();
}
/* The apparition. Its opacity and size follow the haunt level, so the
* haunt level is visible rather than merely reported. */
var apparitionAlpha = clamp(house.hauntLevel * 0.85, 0, 0.85);
if (apparitionAlpha > 0.02) {
var ax = w * (0.5 + Math.sin(house.time * 0.35) * 0.12);
var ay = h * (0.42 + Math.sin(house.time * 0.21) * 0.05);
var ar = Math.min(w, h) * (0.1 + house.hauntLevel * 0.12);
var aura = ctx.createRadialGradient(ax, ay, 0, ax, ay, ar * 2.4);
aura.addColorStop(0, hexToRgba(spirit.color, apparitionAlpha * 0.75));
aura.addColorStop(1, hexToRgba(spirit.color, 0));
ctx.fillStyle = aura;
ctx.beginPath();
ctx.arc(ax, ay, ar * 2.4, 0, TAU);
ctx.fill();
ctx.fillStyle = hexToRgba(spirit.color, apparitionAlpha);
ctx.beginPath();
ctx.ellipse(ax, ay, ar * 0.55, ar, 0, 0, TAU);
ctx.fill();
/* Trailing veil. */
ctx.strokeStyle = hexToRgba(spirit.color, apparitionAlpha * 0.5);
ctx.lineWidth = 2;
ctx.beginPath();
for (var t = 0; t <= 1; t += 0.05) {
var tx = ax + Math.sin(house.time * 1.4 + t * 6) * ar * 0.5;
var ty = ay + ar + t * ar * 1.6;
if (t === 0) ctx.moveTo(tx, ty); else ctx.lineTo(tx, ty);
}
ctx.stroke();
}
/* Embers. */
for (var e = 0; e < house.embers.length; e++) {
var em = house.embers[e];
var ea = 0.25 + Math.sin(em.phase) * 0.2;
ctx.fillStyle = 'rgba(255,170,90,' + clamp(ea * light * 2, 0, 0.9) + ')';
ctx.beginPath();
ctx.arc(em.x * w, em.y * h, 1.6, 0, TAU);
ctx.fill();
}
/* Dust motes. */
ctx.fillStyle = 'rgba(230,230,240,0.18)';
for (var d = 0; d < house.dust.length; d++) {
var p = house.dust[d];
ctx.beginPath();
ctx.arc(p.x * w, p.y * h, p.r, 0, TAU);
ctx.fill();
}
/* Vignette. */
var vig = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.25, w / 2, h / 2, Math.max(w, h) * 0.75);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, w, h);
/* Room label. */
ctx.fillStyle = 'rgba(240,230,210,0.7)';
ctx.font = '600 12px "Segoe UI", sans-serif';
ctx.fillText(room.label.toUpperCase(), 14, 24);
if (house.paused) {
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '600 13px "Segoe UI", sans-serif';
ctx.fillText('HELD', 14, 44);
}
};
function shade(hex, factor) {
var n = parseInt(hex.slice(1), 16);
var r = clamp(Math.round(((n >> 16) & 255) * factor), 0, 255);
var g = clamp(Math.round(((n >> 8) & 255) * factor), 0, 255);
var b = clamp(Math.round((n & 255) * factor), 0, 255);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function hexToRgba(hex, alpha) {
var n = parseInt(hex.slice(1), 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
}
/* ------------------------------------------------------------------ *
* Exhibit
* ------------------------------------------------------------------ */
function HauntedHouseExhibit(options) {
this.canvas = options.canvas;
this.house = new House();
this.audio = new AudioService();
this.renderer = new Renderer(this.canvas, this.house);
this.announcer = options.announcer;
this.onChange = options.onChange || function () {};
this._lastFrame = 0;
this._running = false;
this._boundFrame = this._frame.bind(this);
}
HauntedHouseExhibit.prototype.start = function () {
if (this._running) return;
this._running = true;
this._lastFrame = performance.now();
requestAnimationFrame(this._boundFrame);
};
HauntedHouseExhibit.prototype._frame = function (now) {
if (!this._running) return;
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
this._lastFrame = now;
this.house.step(dt);
this.renderer.draw();
requestAnimationFrame(this._boundFrame);
};
HauntedHouseExhibit.prototype.resize = function () { this.renderer.resize(); };
/* ------------------------------------------------------------------ *
* Absolute, idempotent setters
* ------------------------------------------------------------------ */
HauntedHouseExhibit.prototype.setRoom = function (id) {
if (this.house.room === id) return { changed: false };
this.house.room = id;
this.onChange();
return { changed: true };
};
HauntedHouseExhibit.prototype.setSpirit = function (id) {
if (this.house.spirit === id) return { changed: false };
this.house.spirit = id;
this.onChange();
return { changed: true };
};
HauntedHouseExhibit.prototype.setHauntLevel = function (value) {
if (this.house.hauntLevel === value) return { changed: false };
this.house.hauntLevel = value;
this.audio.setIntensity(value);
this.onChange();
return { changed: true };
};
HauntedHouseExhibit.prototype.setFogDensity = function (value) {
if (this.house.fogDensity === value) return { changed: false };
this.house.fogDensity = value;
this.onChange();
return { changed: true };
};
HauntedHouseExhibit.prototype.setCandlelight = function (value) {
if (this.house.candlelight === value) return { changed: false };
this.house.candlelight = value;
this.onChange();
return { changed: true };
};
HauntedHouseExhibit.prototype.setAudioEnabled = function (on) {
if (this.house.audioEnabled === on) return { changed: false };
this.house.audioEnabled = on;
if (!on) this.audio.setIntensity(0);
else this.audio.setIntensity(this.house.hauntLevel);
this.onChange();
return { changed: true };
};
HauntedHouseExhibit.prototype.setPaused = function (on) {
if (this.house.paused === on) return { changed: false };
this.house.paused = on;
this.onChange();
return { changed: true };
};
/* ------------------------------------------------------------------ *
* Real actions
* ------------------------------------------------------------------ */
HauntedHouseExhibit.prototype.moan = function () {
if (!this.house.audioEnabled) {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' };
}
if (!this.audio.moan()) {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' };
}
this.announcer.flash('A long moan rolls through the ' + this.house.room + '.');
this.onChange();
return { ok: true };
};
HauntedHouseExhibit.prototype.slamDoor = function () {
if (!this.house.audioEnabled) {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' };
}
if (!this.audio.slam()) {
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' };
}
this.house.hauntLevel = clamp(this.house.hauntLevel + 0.12, 0, 1);
this.announcer.flash('A door slams somewhere above.');
this.onChange();
return { ok: true };
};
HauntedHouseExhibit.prototype.manifest = function () {
this.house.hauntLevel = 1;
this.audio.setIntensity(1);
this.announcer.flash('The apparition manifests fully.');
this.onChange();
return { ok: true };
};
HauntedHouseExhibit.prototype.calm = function () {
this.house.hauntLevel = 0;
this.audio.setIntensity(0);
this.announcer.flash('The house settles into silence.');
this.onChange();
return { ok: true };
};
/* ------------------------------------------------------------------ *
* Reads
* ------------------------------------------------------------------ */
HauntedHouseExhibit.prototype.read = function (targetId) {
var h = this.house;
switch (targetId) {
case 'room.current': return h.room;
case 'room.spirit': return h.spirit;
case 'room.haunt-level': return Math.round(h.hauntLevel * 1000) / 1000;
case 'room.fog-density': return h.fogDensity;
case 'room.candlelight': return h.candlelight;
case 'room.audio-enabled': return h.audioEnabled;
case 'room.paused': return h.paused;
case 'telemetry.audio-state': return this.audio.state;
case 'telemetry.manifestations': return this._manifestations;
case 'telemetry.room-damp': return findById(ROOMS, h.room).damp;
default: return null;
}
};
HauntedHouseExhibit.prototype._manifestations = 0;
/* ------------------------------------------------------------------ *
* Native UI
* ------------------------------------------------------------------ */
function HauntedHouseUI(exhibit, announcer) {
this.exhibit = exhibit;
this.announcer = announcer;
this.nodes = {
room: document.getElementById('room-select'),
spirit: document.getElementById('spirit-select'),
haunt: document.getElementById('haunt-range'),
hauntReadout: document.getElementById('haunt-readout'),
fog: document.getElementById('fog-range'),
fogReadout: document.getElementById('fog-readout'),
candle: document.getElementById('candle-range'),
candleReadout: document.getElementById('candle-readout'),
audio: document.getElementById('audio-button'),
pause: document.getElementById('pause-button'),
moan: document.getElementById('moan-button'),
slam: document.getElementById('slam-button'),
manifest: document.getElementById('manifest-button'),
calm: document.getElementById('calm-button'),
audioState: document.getElementById('audio-state')
};
this._buildOptions();
this._bind();
this.sync();
}
HauntedHouseUI.prototype._buildOptions = function () {
var r = this.nodes.room;
for (var i = 0; i < ROOMS.length; i++) {
var opt = document.createElement('option');
opt.value = ROOMS[i].id;
opt.textContent = ROOMS[i].label;
r.appendChild(opt);
}
var s = this.nodes.spirit;
for (var j = 0; j < SPIRITS.length; j++) {
var sopt = document.createElement('option');
sopt.value = SPIRITS[j].id;
sopt.textContent = SPIRITS[j].label;
s.appendChild(sopt);
}
};
HauntedHouseUI.prototype._bind = function () {
var self = this;
var ex = this.exhibit;
this.nodes.room.addEventListener('change', function (e) {
self.dispatch('room.current', e.target.value);
});
this.nodes.spirit.addEventListener('change', function (e) {
self.dispatch('room.spirit', e.target.value);
});
this.nodes.haunt.addEventListener('input', function (e) {
self.dispatch('room.haunt-level', Number(e.target.value));
});
this.nodes.fog.addEventListener('input', function (e) {
self.dispatch('room.fog-density', Number(e.target.value));
});
this.nodes.candle.addEventListener('input', function (e) {
self.dispatch('room.candlelight', Number(e.target.value));
});
this.nodes.audio.addEventListener('click', function () {
self.dispatch('room.audio-enabled', !ex.house.audioEnabled);
});
this.nodes.pause.addEventListener('click', function () {
self.dispatch('room.paused', !ex.house.paused);
});
/* The first real gesture is what unlocks the audio engine. This is the
* browser's rule, not the exhibit's, so the capability genuinely moves
* available -> loading -> ready here. */
this.nodes.moan.addEventListener('click', function () {
self._ensureAudio();
self.invoke('action.moan');
});
this.nodes.slam.addEventListener('click', function () {
self._ensureAudio();
self.invoke('action.slam-door');
});
this.nodes.manifest.addEventListener('click', function () {
self._ensureAudio();
self.invoke('action.manifest');
});
this.nodes.calm.addEventListener('click', function () {
self.invoke('action.calm');
});
};
HauntedHouseUI.prototype._ensureAudio = function () {
if (this.exhibit.audio.state === 'ready') return;
this.exhibit.audio.prepare();
this.sync();
};
HauntedHouseUI.prototype.dispatch = function (targetId, value) {
if (typeof this.dispatchOverride === 'function') {
this.dispatchOverride(targetId, value);
return;
}
this._fallbackSet(targetId, value);
};
HauntedHouseUI.prototype.invoke = function (targetId) {
if (typeof this.invokeOverride === 'function') {
this.invokeOverride(targetId);
return;
}
this._fallbackInvoke(targetId);
};
HauntedHouseUI.prototype._fallbackSet = function (targetId, value) {
var ex = this.exhibit;
switch (targetId) {
case 'room.current': ex.setRoom(value); break;
case 'room.spirit': ex.setSpirit(value); break;
case 'room.haunt-level': ex.setHauntLevel(value); break;
case 'room.fog-density': ex.setFogDensity(value); break;
case 'room.candlelight': ex.setCandlelight(value); break;
case 'room.audio-enabled': ex.setAudioEnabled(value); break;
case 'room.paused': ex.setPaused(value); break;
default: break;
}
this.sync();
};
HauntedHouseUI.prototype._fallbackInvoke = function (targetId) {
var ex = this.exhibit;
if (targetId === 'action.moan') ex.moan();
else if (targetId === 'action.slam-door') ex.slamDoor();
else if (targetId === 'action.manifest') ex.manifest();
else if (targetId === 'action.calm') ex.calm();
this.sync();
};
HauntedHouseUI.prototype.sync = function () {
var h = this.exhibit.house;
var n = this.nodes;
var Shell = window.XZBTShell;
if (n.room.value !== h.room) n.room.value = h.room;
if (n.spirit.value !== h.spirit) n.spirit.value = h.spirit;
if (Number(n.haunt.value) !== h.hauntLevel) n.haunt.value = String(h.hauntLevel);
if (Number(n.fog.value) !== h.fogDensity) n.fog.value = String(h.fogDensity);
if (Number(n.candle.value) !== h.candlelight) n.candle.value = String(h.candlelight);
n.hauntReadout.textContent = Shell.formatPercent(h.hauntLevel);
n.fogReadout.textContent = Shell.formatPercent(h.fogDensity);
n.candleReadout.textContent = Shell.formatPercent(h.candlelight);
n.audio.setAttribute('aria-pressed', h.audioEnabled ? 'true' : 'false');
n.audio.textContent = h.audioEnabled ? 'Audio on' : 'Audio off';
n.pause.setAttribute('aria-pressed', h.paused ? 'true' : 'false');
n.pause.textContent = h.paused ? 'Resume' : 'Hold';
var state = this.exhibit.audio.state;
n.audioState.textContent = state;
n.audioState.setAttribute('data-state', state);
};
window.HauntedHouseExhibit = HauntedHouseExhibit;
window.HauntedHouseUI = HauntedHouseUI;
window.HauntedHouseData = { ROOMS: ROOMS, SPIRITS: SPIRITS };
})();
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Haunted House — XZBT Reference Exhibit</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>Haunted House</h1>
<span class="subtitle">Fifth room &mdash; live haunt</span>
<span class="status" id="contract-status">standalone</span>
</header>
<main class="stage">
<canvas id="room-canvas" aria-label="Haunted house room view"></canvas>
</main>
<aside class="panel">
<section>
<h2>Presence</h2>
<div class="control">
<label for="room-select">Room</label>
<select id="room-select"></select>
</div>
<div class="control">
<label for="spirit-select">Spirit</label>
<select id="spirit-select"></select>
</div>
<div class="control">
<label for="haunt-range">
<span>Haunt level</span>
<span class="readout" id="haunt-readout">30%</span>
</label>
<input type="range" id="haunt-range" min="0" max="1" step="0.01" value="0.3">
</div>
</section>
<section>
<h2>Atmosphere</h2>
<div class="control">
<label for="fog-range">
<span>Fog density</span>
<span class="readout" id="fog-readout">25%</span>
</label>
<input type="range" id="fog-range" min="0" max="1" step="0.01" value="0.25">
</div>
<div class="control">
<label for="candle-range">
<span>Candlelight</span>
<span class="readout" id="candle-readout">55%</span>
</label>
<input type="range" id="candle-range" min="0" max="1" step="0.01" value="0.55">
</div>
<div class="toggle-row">
<span>Audio engine: <strong id="audio-state" data-state="available">available</strong></span>
<button id="audio-button" type="button" aria-pressed="true">Audio on</button>
</div>
<div class="toggle-row">
<span>Simulation</span>
<button id="pause-button" type="button" aria-pressed="false">Hold</button>
</div>
</section>
<section>
<h2>Actions</h2>
<div class="button-row">
<button id="moan-button" type="button">Moan</button>
<button id="slam-button" type="button">Slam door</button>
</div>
<div class="button-row" style="margin-top:6px">
<button id="manifest-button" type="button">Manifest</button>
<button id="calm-button" type="button">Calm</button>
</div>
</section>
</aside>
<footer class="exhibit-footer">
<span class="announcement-label">House</span>
<span class="announcement-text is-idle" id="announcement">The house is quiet &mdash; awaiting instruction.</span>
</footer>
</div>
<script src="../shared/contract-core.js"></script>
<script src="../shared/exhibit-shell.js"></script>
<script src="../shared/host-transport.js"></script>
<script src="exhibit.js"></script>
<script src="contract-adapter.js"></script>
<script src="boot.js"></script>
</body>
</html>
@@ -0,0 +1,65 @@
/* Haunted House — domain styling only. */
body {
background: #0d0a08;
color: #f0e6d8;
}
.exhibit-header {
background: linear-gradient(90deg, rgba(120, 70, 30, 0.35), rgba(255, 255, 255, 0.03));
border-color: rgba(220, 170, 110, 0.22);
}
.exhibit-header h1 { color: #f0d9b5; }
.stage {
border-color: rgba(220, 170, 110, 0.25);
box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.9);
}
.panel {
background: rgba(90, 60, 30, 0.2);
border-color: rgba(220, 170, 110, 0.18);
}
.panel h2 { color: #e0b98a; }
input[type="range"] { accent-color: #e0a860; }
button {
background: rgba(224, 168, 96, 0.12);
border-color: rgba(220, 170, 110, 0.35);
color: #f0e6d8;
}
button:hover { background: rgba(224, 168, 96, 0.24); }
button[aria-pressed="true"] {
background: rgba(224, 168, 96, 0.4);
border-color: rgba(245, 210, 160, 0.7);
}
select {
background: rgba(20, 14, 10, 0.85);
border-color: rgba(220, 170, 110, 0.3);
}
.exhibit-footer {
background: rgba(90, 60, 30, 0.2);
border-color: rgba(220, 170, 110, 0.18);
}
.exhibit-footer .announcement-label { color: #e0b98a; opacity: 0.7; }
.exhibit-header .status { color: #e0b98a; }
/* The audio engine state is a real capability, so it is shown plainly. */
#audio-state {
font-weight: 600;
letter-spacing: 0.04em;
}
#audio-state[data-state="available"] { color: #d8c090; }
#audio-state[data-state="loading"] { color: #ffd27a; }
#audio-state[data-state="ready"] { color: #9fe0a0; }
#audio-state[data-state="error"] { color: #ff8a7a; }
@@ -0,0 +1,44 @@
/*
* Planetarium — composition root. Last file to load.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var announcer = new Shell.Announcer({
node: Shell.el('announcement'),
idleText: 'Dome idle \u2014 awaiting instruction.',
maxLength: 120
});
var exhibit = new window.PlanetariumExhibit({
canvas: Shell.el('sky-canvas'),
announcer: announcer
});
var ui = new window.PlanetariumUI(exhibit, announcer);
var core = window.PlanetariumContract.create(exhibit, ui);
var transport = new window.XZBTHostTransport({
core: core,
onMessage: function (request, response) {
if (response.type === 'hello.result') {
Shell.setText(Shell.el('contract-status'), 'host attached');
}
}
});
exhibit.start();
window.addEventListener('resize', function () { exhibit.resize(); });
window.__exhibit = {
core: core,
exhibit: exhibit,
ui: ui,
transport: transport,
announcer: announcer
};
})();
@@ -0,0 +1,215 @@
/*
* Planetarium — XZBT Exhibit Contract 5.2 adapter.
*
* Declares the catalog, binds targets to real exhibit services, and routes
* the native UI through the canonical mutation path. No state of its own.
*/
(function () {
'use strict';
var Core = window.XZBTContractCore;
var IDENTITY = {
product: 'Planetarium',
version: '1.0.0',
build: 'reference-exhibit'
};
var DESCRIPTORS = [
{
id: 'sky.constellation',
kind: 'selection',
label: 'Constellation',
description: 'Constellation currently tracked and drawn.',
options: [
{ value: 'orion', label: 'Orion' },
{ value: 'cygnus', label: 'Cygnus' },
{ value: 'scorpius', label: 'Scorpius' },
{ value: 'ursa-major', label: 'Ursa Major' },
{ value: 'cassiopeia', label: 'Cassiopeia' },
{ value: 'lyra', label: 'Lyra' }
],
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.magnitude-limit',
kind: 'range',
label: 'Magnitude limit',
description: 'Faintest stellar magnitude rendered. Higher values show more stars.',
min: 1, max: 6, step: 0.1, unit: 'mag',
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.azimuth',
kind: 'range',
label: 'Azimuth',
description: 'Compass bearing of the view, 0 to 1 of a full turn.',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.altitude',
kind: 'range',
label: 'Altitude',
description: 'Elevation of the view above the horizon, 0 to 1.',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.time-scale',
kind: 'range',
label: 'Time scale',
description: 'Rate at which sky time advances.',
min: 0, max: 4, step: 0.05, unit: 'x',
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.filter',
kind: 'selection',
label: 'Filter',
description: 'Optical filter applied to the view.',
options: [
{ value: 'none', label: 'None' },
{ value: 'light-pollution', label: 'Light pollution' },
{ value: 'h-alpha', label: 'H-alpha' },
{ value: 'oxygen-iii', label: 'Oxygen III' }
],
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.tracking',
kind: 'state',
valueType: 'boolean',
label: 'Tracking',
description: 'Whether the mount is tracking the selected constellation.',
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'sky.paused',
kind: 'state',
valueType: 'boolean',
label: 'Sky frozen',
description: 'Whether sky time is frozen.',
readable: true, writable: true, restorable: true,
category: 'sky', requires: []
},
{
id: 'action.advance-time',
kind: 'impulse',
label: 'Advance time',
description: 'Advance sky time. Optional argument: minutes (1 to 240).',
arguments: [
{ name: 'minutes', type: 'number', required: false, min: 1, max: 240, default: 10 }
],
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.reset-sky',
kind: 'impulse',
label: 'Reset sky',
description: 'Return sky time to epoch.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.toggle-tracking',
kind: 'impulse',
label: 'Toggle tracking',
description: 'Engage or disengage the mount.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'telemetry.visible-stars',
kind: 'state',
valueType: 'number',
label: 'Visible stars',
description: 'Stars currently above the magnitude limit.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.sky-time',
kind: 'state',
valueType: 'number',
unit: 'h',
label: 'Sky time',
description: 'Elapsed sky time in hours.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.moon-phase',
kind: 'state',
valueType: 'number',
label: 'Moon phase',
description: 'Lunar phase as a fraction of the cycle.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
}
];
function createPlanetariumContract(exhibit, ui) {
var catalog = new Core.Catalog(DESCRIPTORS);
var capabilities = new Core.CapabilityRegistry();
capabilities.declare('render', 'ready');
var setters = {
'sky.constellation': function (v) { return exhibit.setConstellation(v); },
'sky.magnitude-limit': function (v) { return exhibit.setMagnitudeLimit(v); },
'sky.azimuth': function (v) { return exhibit.setAzimuth(v); },
'sky.altitude': function (v) { return exhibit.setAltitude(v); },
'sky.time-scale': function (v) { return exhibit.setTimeScale(v); },
'sky.filter': function (v) { return exhibit.setFilter(v); },
'sky.tracking': function (v) { return exhibit.setTracking(v); },
'sky.paused': function (v) { return exhibit.setPaused(v); }
};
var readers = {};
var ids = catalog.ids();
for (var i = 0; i < ids.length; i++) {
(function (id) {
readers[id] = function () { return exhibit.read(id); };
})(ids[i]);
}
var actions = {
'action.advance-time': function (args) { return exhibit.advanceTime(args); },
'action.reset-sky': function () { return exhibit.resetSky(); },
'action.toggle-tracking': function () { return exhibit.toggleTracking(); }
};
var core = new Core.ContractCore({
identity: IDENTITY,
catalog: catalog,
capabilities: capabilities,
setters: setters,
readers: readers,
actions: actions
});
ui.dispatchOverride = function (targetId, value) {
core.applyMutation(targetId, value, 'ui');
ui.sync();
};
ui.invokeOverride = function (targetId, args) {
core.invokeAction(targetId, args || {}, 'ui');
ui.sync();
};
exhibit.onChange = function () { ui.sync(); };
return core;
}
window.PlanetariumContract = { create: createPlanetariumContract, IDENTITY: IDENTITY };
})();
@@ -0,0 +1,564 @@
/*
* Planetarium — exhibit behaviour.
*
* Owns the sky model, the ephemeris, the canvas rendering, and the native
* controls. No contract awareness: no target IDs, no events, no revisions.
*
* The exhibit's own services (absolute setters and real actions) are what
* the contract layer binds to, so the native UI and a future host share one
* code path.
*/
(function () {
'use strict';
var TAU = Math.PI * 2;
var CATALOG = [
{ id: 'orion', label: 'Orion', stars: 9, brightness: 1.0, hue: '#cfe4ff' },
{ id: 'cygnus', label: 'Cygnus', stars: 7, brightness: 0.85, hue: '#dbe8ff' },
{ id: 'scorpius', label: 'Scorpius', stars: 8, brightness: 0.95, hue: '#ffe0c4' },
{ id: 'ursa-major', label: 'Ursa Major', stars: 7, brightness: 0.8, hue: '#e6f0ff' },
{ id: 'cassiopeia', label: 'Cassiopeia', stars: 5, brightness: 0.75, hue: '#f0e6ff' },
{ id: 'lyra', label: 'Lyra', stars: 6, brightness: 0.9, hue: '#e8f4ff' }
];
var BODIES = [
{ id: 'moon', label: 'Moon', radius: 0.055, period: 0.09, color: '#e8eef7', phase: 0.2 },
{ id: 'mars', label: 'Mars', radius: 0.085, period: 0.16, color: '#e07a52', phase: 1.1 },
{ id: 'jupiter', label: 'Jupiter', radius: 0.115, period: 0.27, color: '#e6c79a', phase: 2.4 },
{ id: 'saturn', label: 'Saturn', radius: 0.145, period: 0.41, color: '#e8d9a8', phase: 4.0, ring: true }
];
var FILTERS = [
{ id: 'none', label: 'None', tint: null, gain: 1.0 },
{ id: 'light-pollution', label: 'Light pollution', tint: '#3a2a1a', gain: 0.55 },
{ id: 'h-alpha', label: 'H-alpha', tint: '#5a1020', gain: 0.8 },
{ id: 'oxygen-iii', label: 'Oxygen III', tint: '#0d3a4a', gain: 0.8 }
];
var MAX_MAGNITUDE = 6.0;
var MIN_MAGNITUDE = 1.0;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function findById(list, id) {
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
return null;
}
/* Deterministic pseudo-random so a constellation always looks the same. */
function hash(n) {
var x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
/* ------------------------------------------------------------------ *
* Sky model
* ------------------------------------------------------------------ */
function Sky() {
this.constellation = 'orion';
this.magnitudeLimit = 4.5;
this.azimuth = 0.25;
this.altitude = 0.5;
this.timeScale = 0.5;
this.filter = 'none';
this.tracking = true;
this.paused = false;
this.skyTime = 0;
this.stars = [];
this._buildStars();
}
Sky.prototype._buildStars = function () {
this.stars = [];
var count = 420;
for (var i = 0; i < count; i++) {
var mag = MIN_MAGNITUDE + hash(i * 3.1) * (MAX_MAGNITUDE - MIN_MAGNITUDE);
this.stars.push({
x: hash(i * 1.7),
y: hash(i * 2.3) * 0.92,
mag: mag,
twinkle: hash(i * 5.9) * TAU,
hue: hash(i * 7.3) > 0.82 ? '#ffd9b0' : (hash(i * 9.1) > 0.7 ? '#c9d8ff' : '#ffffff')
});
}
};
Sky.prototype.step = function (dt) {
if (this.paused) return;
this.skyTime += dt * this.timeScale;
};
/** Position of a body on the sky dome, in normalised 0..1 coordinates. */
Sky.prototype.bodyPosition = function (body) {
var angle = body.phase + this.skyTime * TAU * body.period;
var cx = 0.5 + (this.azimuth - 0.5) * 0.6;
var cy = 0.5 + (this.altitude - 0.5) * 0.6;
return {
x: cx + Math.cos(angle) * body.radius * 3.2,
y: cy + Math.sin(angle) * body.radius * 1.9
};
};
/* ------------------------------------------------------------------ *
* Rendering
* ------------------------------------------------------------------ */
function Renderer(canvas, sky) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.sky = sky;
this.width = 0;
this.height = 0;
this.resize();
}
Renderer.prototype.resize = function () {
var rect = this.canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
this.width = Math.max(1, Math.round(rect.width));
this.height = Math.max(1, Math.round(rect.height));
this.canvas.width = Math.round(this.width * dpr);
this.canvas.height = Math.round(this.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
Renderer.prototype.draw = function () {
var ctx = this.ctx;
var w = this.width;
var h = this.height;
var sky = this.sky;
var filter = findById(FILTERS, sky.filter);
var constellation = findById(CATALOG, sky.constellation);
ctx.clearRect(0, 0, w, h);
/* Sky dome. */
var grad = ctx.createRadialGradient(w * 0.5, h * 0.55, 0, w * 0.5, h * 0.55, Math.max(w, h) * 0.75);
grad.addColorStop(0, '#0b1226');
grad.addColorStop(1, '#03050c');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
/* Horizon glow. */
var horizon = ctx.createLinearGradient(0, h * 0.72, 0, h);
horizon.addColorStop(0, 'rgba(20,40,80,0)');
horizon.addColorStop(1, 'rgba(30,60,110,0.35)');
ctx.fillStyle = horizon;
ctx.fillRect(0, h * 0.72, w, h * 0.28);
/* Stars, filtered by the magnitude limit. */
for (var i = 0; i < sky.stars.length; i++) {
var s = sky.stars[i];
if (s.mag > sky.magnitudeLimit) continue;
var norm = 1 - (s.mag - MIN_MAGNITUDE) / (MAX_MAGNITUDE - MIN_MAGNITUDE);
var alpha = clamp(norm * 0.95 + 0.05, 0, 1) * filter.gain;
var twinkle = 0.85 + Math.sin(sky.skyTime * 2.4 + s.twinkle) * 0.15;
var r = 0.6 + norm * 1.9;
ctx.globalAlpha = clamp(alpha * twinkle, 0, 1);
ctx.fillStyle = s.hue;
ctx.beginPath();
ctx.arc(s.x * w, s.y * h, r, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
/* The selected constellation, drawn as a connected figure. */
var baseX = (0.5 + (sky.azimuth - 0.5) * 0.7) * w;
var baseY = (0.42 + (sky.altitude - 0.5) * 0.5) * h;
var scale = Math.min(w, h) * 0.16;
var points = [];
for (var k = 0; k < constellation.stars; k++) {
var angle = (k / constellation.stars) * TAU + hash(k * 4.4) * 0.6;
var dist = 0.35 + hash(k * 6.1) * 0.65;
points.push({
x: baseX + Math.cos(angle) * dist * scale,
y: baseY + Math.sin(angle) * dist * scale * 0.7
});
}
ctx.strokeStyle = 'rgba(150,200,255,' + (0.28 * filter.gain) + ')';
ctx.lineWidth = 1;
ctx.beginPath();
for (var p = 0; p < points.length; p++) {
var next = points[(p + 1) % points.length];
ctx.moveTo(points[p].x, points[p].y);
ctx.lineTo(next.x, next.y);
}
ctx.stroke();
for (var q = 0; q < points.length; q++) {
ctx.fillStyle = constellation.hue;
ctx.globalAlpha = clamp(constellation.brightness * filter.gain, 0, 1);
ctx.beginPath();
ctx.arc(points[q].x, points[q].y, 2.4, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
/* Label the constellation with textContent-equivalent canvas text. */
ctx.fillStyle = 'rgba(190,220,255,0.75)';
ctx.font = '600 12px "Segoe UI", sans-serif';
ctx.fillText(constellation.label, baseX - scale * 0.5, baseY + scale * 1.15);
/* Solar-system bodies. */
for (var b = 0; b < BODIES.length; b++) {
var body = BODIES[b];
var pos = sky.bodyPosition(body);
var bx = pos.x * w;
var by = pos.y * h;
var br = body.radius * Math.min(w, h) * 0.5;
ctx.globalAlpha = clamp(filter.gain, 0, 1);
ctx.fillStyle = body.color;
ctx.beginPath();
ctx.arc(bx, by, br, 0, TAU);
ctx.fill();
if (body.ring) {
ctx.strokeStyle = 'rgba(232,217,168,0.7)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(bx, by, br * 1.9, br * 0.6, 0.4, 0, TAU);
ctx.stroke();
}
ctx.fillStyle = 'rgba(200,220,255,0.6)';
ctx.font = '11px "Segoe UI", sans-serif';
ctx.fillText(body.label, bx + br + 5, by + 4);
ctx.globalAlpha = 1;
}
/* Filter tint over the whole frame. */
if (filter.tint) {
ctx.globalAlpha = 0.22;
ctx.fillStyle = filter.tint;
ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = 1;
}
/* Tracking reticle. */
if (sky.tracking) {
ctx.strokeStyle = 'rgba(120,200,255,0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(baseX, baseY, scale * 1.35, 0, TAU);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(baseX - scale * 1.5, baseY);
ctx.lineTo(baseX - scale * 1.2, baseY);
ctx.moveTo(baseX + scale * 1.2, baseY);
ctx.lineTo(baseX + scale * 1.5, baseY);
ctx.stroke();
}
if (sky.paused) {
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '600 13px "Segoe UI", sans-serif';
ctx.fillText('SKY FROZEN', 14, 24);
}
};
/* ------------------------------------------------------------------ *
* Exhibit
* ------------------------------------------------------------------ */
function PlanetariumExhibit(options) {
this.canvas = options.canvas;
this.sky = new Sky();
this.renderer = new Renderer(this.canvas, this.sky);
this.announcer = options.announcer;
this.onChange = options.onChange || function () {};
this._lastFrame = 0;
this._running = false;
this._boundFrame = this._frame.bind(this);
}
PlanetariumExhibit.prototype.start = function () {
if (this._running) return;
this._running = true;
this._lastFrame = performance.now();
requestAnimationFrame(this._boundFrame);
};
PlanetariumExhibit.prototype._frame = function (now) {
if (!this._running) return;
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
this._lastFrame = now;
this.sky.step(dt);
this.renderer.draw();
requestAnimationFrame(this._boundFrame);
};
PlanetariumExhibit.prototype.resize = function () { this.renderer.resize(); };
/* ------------------------------------------------------------------ *
* Absolute, idempotent setters
* ------------------------------------------------------------------ */
PlanetariumExhibit.prototype.setConstellation = function (id) {
if (this.sky.constellation === id) return { changed: false };
this.sky.constellation = id;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setMagnitudeLimit = function (value) {
if (this.sky.magnitudeLimit === value) return { changed: false };
this.sky.magnitudeLimit = value;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setAzimuth = function (value) {
if (this.sky.azimuth === value) return { changed: false };
this.sky.azimuth = value;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setAltitude = function (value) {
if (this.sky.altitude === value) return { changed: false };
this.sky.altitude = value;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setTimeScale = function (value) {
if (this.sky.timeScale === value) return { changed: false };
this.sky.timeScale = value;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setFilter = function (id) {
if (this.sky.filter === id) return { changed: false };
this.sky.filter = id;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setTracking = function (on) {
if (this.sky.tracking === on) return { changed: false };
this.sky.tracking = on;
this.onChange();
return { changed: true };
};
PlanetariumExhibit.prototype.setPaused = function (on) {
if (this.sky.paused === on) return { changed: false };
this.sky.paused = on;
this.onChange();
return { changed: true };
};
/* ------------------------------------------------------------------ *
* Real actions
* ------------------------------------------------------------------ */
PlanetariumExhibit.prototype.advanceTime = function (args) {
var minutes = 10;
if (args && typeof args.minutes === 'number') {
if (!isFinite(args.minutes) || args.minutes < 1 || args.minutes > 240) {
return { ok: false, code: 'INVALID_ARGUMENTS', message: 'minutes must be between 1 and 240.' };
}
minutes = Math.round(args.minutes);
}
this.sky.skyTime += minutes / 60;
this.announcer.flash('Advanced sky time by ' + minutes + ' minutes.');
this.onChange();
return { ok: true, args: { minutes: minutes } };
};
PlanetariumExhibit.prototype.resetSky = function () {
this.sky.skyTime = 0;
this.announcer.flash('Sky time reset to epoch.');
this.onChange();
return { ok: true };
};
PlanetariumExhibit.prototype.toggleTracking = function () {
this.sky.tracking = !this.sky.tracking;
this.announcer.flash(this.sky.tracking ? 'Tracking engaged.' : 'Tracking disengaged.');
this.onChange();
return { ok: true };
};
/* ------------------------------------------------------------------ *
* Reads
* ------------------------------------------------------------------ */
PlanetariumExhibit.prototype.read = function (targetId) {
var s = this.sky;
switch (targetId) {
case 'sky.constellation': return s.constellation;
case 'sky.magnitude-limit': return s.magnitudeLimit;
case 'sky.azimuth': return s.azimuth;
case 'sky.altitude': return s.altitude;
case 'sky.time-scale': return s.timeScale;
case 'sky.filter': return s.filter;
case 'sky.tracking': return s.tracking;
case 'sky.paused': return s.paused;
case 'telemetry.visible-stars': return this._visibleStarCount();
case 'telemetry.sky-time': return Math.round(s.skyTime * 100) / 100;
case 'telemetry.moon-phase': return Math.round(((s.skyTime * 0.09) % 1) * 100) / 100;
default: return null;
}
};
PlanetariumExhibit.prototype._visibleStarCount = function () {
var count = 0;
for (var i = 0; i < this.sky.stars.length; i++) {
if (this.sky.stars[i].mag <= this.sky.magnitudeLimit) count++;
}
return count;
};
/* ------------------------------------------------------------------ *
* Native UI
* ------------------------------------------------------------------ */
function PlanetariumUI(exhibit, announcer) {
this.exhibit = exhibit;
this.announcer = announcer;
this.nodes = {
constellation: document.getElementById('constellation-select'),
magnitude: document.getElementById('magnitude-range'),
magnitudeReadout: document.getElementById('magnitude-readout'),
azimuth: document.getElementById('azimuth-range'),
azimuthReadout: document.getElementById('azimuth-readout'),
altitude: document.getElementById('altitude-range'),
altitudeReadout: document.getElementById('altitude-readout'),
timeScale: document.getElementById('timescale-range'),
timeScaleReadout: document.getElementById('timescale-readout'),
filter: document.getElementById('filter-select'),
tracking: document.getElementById('tracking-button'),
pause: document.getElementById('pause-button'),
advance: document.getElementById('advance-button'),
reset: document.getElementById('reset-button')
};
this._buildOptions();
this._bind();
this.sync();
}
PlanetariumUI.prototype._buildOptions = function () {
var c = this.nodes.constellation;
for (var i = 0; i < CATALOG.length; i++) {
var opt = document.createElement('option');
opt.value = CATALOG[i].id;
opt.textContent = CATALOG[i].label;
c.appendChild(opt);
}
var f = this.nodes.filter;
for (var j = 0; j < FILTERS.length; j++) {
var fopt = document.createElement('option');
fopt.value = FILTERS[j].id;
fopt.textContent = FILTERS[j].label;
f.appendChild(fopt);
}
};
PlanetariumUI.prototype._bind = function () {
var self = this;
var ex = this.exhibit;
this.nodes.constellation.addEventListener('change', function (e) {
self.dispatch('sky.constellation', e.target.value);
});
this.nodes.magnitude.addEventListener('input', function (e) {
self.dispatch('sky.magnitude-limit', Number(e.target.value));
});
this.nodes.azimuth.addEventListener('input', function (e) {
self.dispatch('sky.azimuth', Number(e.target.value));
});
this.nodes.altitude.addEventListener('input', function (e) {
self.dispatch('sky.altitude', Number(e.target.value));
});
this.nodes.timeScale.addEventListener('input', function (e) {
self.dispatch('sky.time-scale', Number(e.target.value));
});
this.nodes.filter.addEventListener('change', function (e) {
self.dispatch('sky.filter', e.target.value);
});
this.nodes.tracking.addEventListener('click', function () {
self.dispatch('sky.tracking', !ex.sky.tracking);
});
this.nodes.pause.addEventListener('click', function () {
self.dispatch('sky.paused', !ex.sky.paused);
});
this.nodes.advance.addEventListener('click', function () {
self.invoke('action.advance-time', { minutes: 30 });
});
this.nodes.reset.addEventListener('click', function () {
self.invoke('action.reset-sky');
});
};
PlanetariumUI.prototype.dispatch = function (targetId, value) {
if (typeof this.dispatchOverride === 'function') {
this.dispatchOverride(targetId, value);
return;
}
this._fallbackSet(targetId, value);
};
PlanetariumUI.prototype.invoke = function (targetId, args) {
if (typeof this.invokeOverride === 'function') {
this.invokeOverride(targetId, args);
return;
}
this._fallbackInvoke(targetId, args);
};
PlanetariumUI.prototype._fallbackSet = function (targetId, value) {
var ex = this.exhibit;
switch (targetId) {
case 'sky.constellation': ex.setConstellation(value); break;
case 'sky.magnitude-limit': ex.setMagnitudeLimit(value); break;
case 'sky.azimuth': ex.setAzimuth(value); break;
case 'sky.altitude': ex.setAltitude(value); break;
case 'sky.time-scale': ex.setTimeScale(value); break;
case 'sky.filter': ex.setFilter(value); break;
case 'sky.tracking': ex.setTracking(value); break;
case 'sky.paused': ex.setPaused(value); break;
default: break;
}
this.sync();
};
PlanetariumUI.prototype._fallbackInvoke = function (targetId, args) {
var ex = this.exhibit;
if (targetId === 'action.advance-time') ex.advanceTime(args || {});
else if (targetId === 'action.reset-sky') ex.resetSky();
else if (targetId === 'action.toggle-tracking') ex.toggleTracking();
this.sync();
};
PlanetariumUI.prototype.sync = function () {
var s = this.exhibit.sky;
var n = this.nodes;
var Shell = window.XZBTShell;
if (n.constellation.value !== s.constellation) n.constellation.value = s.constellation;
if (Number(n.magnitude.value) !== s.magnitudeLimit) n.magnitude.value = String(s.magnitudeLimit);
if (Number(n.azimuth.value) !== s.azimuth) n.azimuth.value = String(s.azimuth);
if (Number(n.altitude.value) !== s.altitude) n.altitude.value = String(s.altitude);
if (Number(n.timeScale.value) !== s.timeScale) n.timeScale.value = String(s.timeScale);
if (n.filter.value !== s.filter) n.filter.value = s.filter;
n.magnitudeReadout.textContent = 'mag \u2264 ' + Shell.formatNumber(s.magnitudeLimit, 1);
n.azimuthReadout.textContent = Shell.formatDegrees(s.azimuth);
n.altitudeReadout.textContent = Shell.formatDegrees(s.altitude);
n.timeScaleReadout.textContent = Shell.formatNumber(s.timeScale, 2) + '\u00d7';
n.tracking.setAttribute('aria-pressed', s.tracking ? 'true' : 'false');
n.pause.setAttribute('aria-pressed', s.paused ? 'true' : 'false');
n.pause.textContent = s.paused ? 'Resume sky' : 'Freeze sky';
};
window.PlanetariumExhibit = PlanetariumExhibit;
window.PlanetariumUI = PlanetariumUI;
window.PlanetariumData = { CATALOG: CATALOG, BODIES: BODIES, FILTERS: FILTERS };
})();
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Planetarium — XZBT Reference Exhibit</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>Planetarium</h1>
<span class="subtitle">Sky dome &mdash; live ephemeris</span>
<span class="status" id="contract-status">standalone</span>
</header>
<main class="stage">
<canvas id="sky-canvas" aria-label="Planetarium sky view"></canvas>
</main>
<aside class="panel">
<section>
<h2>Target</h2>
<div class="control">
<label for="constellation-select">Constellation</label>
<select id="constellation-select"></select>
</div>
<div class="control">
<label for="magnitude-range">
<span>Magnitude limit</span>
<span class="readout" id="magnitude-readout">mag &le; 4.5</span>
</label>
<input type="range" id="magnitude-range" min="1" max="6" step="0.1" value="4.5">
</div>
</section>
<section>
<h2>Pointing</h2>
<div class="control">
<label for="azimuth-range">
<span>Azimuth</span>
<span class="readout" id="azimuth-readout">90&deg;</span>
</label>
<input type="range" id="azimuth-range" min="0" max="1" step="0.01" value="0.25">
</div>
<div class="control">
<label for="altitude-range">
<span>Altitude</span>
<span class="readout" id="altitude-readout">180&deg;</span>
</label>
<input type="range" id="altitude-range" min="0" max="1" step="0.01" value="0.5">
</div>
<div class="control">
<label for="timescale-range">
<span>Time scale</span>
<span class="readout" id="timescale-readout">0.50&times;</span>
</label>
<input type="range" id="timescale-range" min="0" max="4" step="0.05" value="0.5">
</div>
</section>
<section>
<h2>Optics</h2>
<div class="control">
<label for="filter-select">Filter</label>
<select id="filter-select"></select>
</div>
<div class="toggle-row">
<span>Mount tracking</span>
<button id="tracking-button" type="button" aria-pressed="true">Tracking</button>
</div>
<div class="toggle-row">
<span>Sky time</span>
<button id="pause-button" type="button" aria-pressed="false">Freeze sky</button>
</div>
</section>
<section>
<h2>Actions</h2>
<div class="button-row">
<button id="advance-button" type="button">Advance 30 min</button>
<button id="reset-button" type="button">Reset sky</button>
</div>
</section>
</aside>
<footer class="exhibit-footer">
<span class="announcement-label">Dome</span>
<span class="announcement-text is-idle" id="announcement">Dome idle &mdash; awaiting instruction.</span>
</footer>
</div>
<script src="../shared/contract-core.js"></script>
<script src="../shared/exhibit-shell.js"></script>
<script src="../shared/host-transport.js"></script>
<script src="exhibit.js"></script>
<script src="contract-adapter.js"></script>
<script src="boot.js"></script>
</body>
</html>
@@ -0,0 +1,54 @@
/* Planetarium — domain styling only. */
body {
background: #05070f;
color: #e2ecff;
}
.exhibit-header {
background: linear-gradient(90deg, rgba(60, 70, 150, 0.35), rgba(255, 255, 255, 0.03));
border-color: rgba(150, 180, 255, 0.22);
}
.exhibit-header h1 { color: #cdd9ff; }
.stage {
border-color: rgba(150, 180, 255, 0.25);
box-shadow: inset 0 0 80px rgba(0, 0, 30, 0.9);
}
.panel {
background: rgba(40, 50, 110, 0.22);
border-color: rgba(150, 180, 255, 0.18);
}
.panel h2 { color: #a9bcff; }
input[type="range"] { accent-color: #8fa8ff; }
button {
background: rgba(143, 168, 255, 0.12);
border-color: rgba(150, 180, 255, 0.35);
color: #e2ecff;
}
button:hover { background: rgba(143, 168, 255, 0.24); }
button[aria-pressed="true"] {
background: rgba(143, 168, 255, 0.4);
border-color: rgba(190, 210, 255, 0.7);
}
select {
background: rgba(6, 10, 26, 0.85);
border-color: rgba(150, 180, 255, 0.3);
}
.exhibit-footer {
background: rgba(40, 50, 110, 0.22);
border-color: rgba(150, 180, 255, 0.18);
}
.exhibit-footer .announcement-label { color: #a9bcff; opacity: 0.7; }
.exhibit-header .status { color: #a9bcff; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
<!DOCTYPE html>
<!--
SciFiAmbientDisplay_v3co.html
Version: v3co (co = Claude Opus 5) Base: v2cs Date: 2026-09-04
CHANGE: Per-theme OBSERVATION layer isolation.
v2cs correctly fixed the `.observation-stage svg > rect:first-child` selector that had
never matched, but the rule was GLOBAL -- uncovering the shared canvas layer stack in
nine themes at once and making every OBSERVATION display read as a variation of one.
v3co keeps the correct selector and makes canvas visibility an explicit, per-theme,
default-deny declaration: see the OBSERVATION_CANVAS manifest above class
ObservationEngine. Rollback point: SciFiAmbientDisplay_v2cs.html (untouched).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sci-Fi Ambience Generator - Publish Build v14co</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body class="theme-lcars-tng">
<!-- Header Frame with Interactive Universe Dropdown -->
<header class="lcars-header">
<div class="universe-selector-container" id="universe-selector-container">
<div class="lcars-elbow-left" id="header-universe-btn" title="Click to Change Sci-Fi Universe">
<span id="header-universe-label">STARFLIGHT</span> <span style="font-size: 0.8rem; margin-left: 4px;">▼</span>
</div>
<div class="universe-dropdown-menu" id="universe-dropdown">
<!-- Injected dynamically from UniverseRegistry -->
</div>
</div>
<div class="lcars-top-bar">
<span id="header-ship-name">USS INTERPRISE-G // MAIN BRIDGE</span>
<div class="header-volume-dock">
<button class="btn-header-mute" id="btn-header-mute" title="Toggle Mute (M)">MUTE</button>
<span class="header-vol-val" id="header-vol-val">75%</span>
</div>
<span id="header-matrix-label">STARFLIGHT SOUND MATRIX v2.9</span>
</div>
<div class="lcars-pill-end"></div>
</header>
<!-- Main Application Container -->
<main class="lcars-container">
<!-- Left Sidebar: Active Universe Presets (Purely segregated per theme) -->
<aside class="left-sidebar">
<div class="sidebar-heading" id="sidebar-title">STARSHIP PRESETS</div>
<div class="preset-list" id="preset-container">
<!-- Dynamically injected preset buttons for active universe only -->
</div>
</aside>
<!-- Center Panel: Master Controls, Presets Description & Channel Synthesizers -->
<section class="center-panel">
<!-- Master Control Card -->
<div class="panel-card">
<div class="panel-header">
<div>
<div class="panel-title" id="current-preset-title">INTERPRISE-G: MAIN BRIDGE</div>
<div class="panel-sub" id="current-preset-desc">Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARD computer chirps.</div>
</div>
<div class="diagnostic-stats" style="width: 220px;">
<div class="stat-box">
<span class="stat-label">AUDIO ENGINE</span>
<span class="stat-value" id="stat-audio-status">STANDBY</span>
</div>
<div class="stat-box">
<span class="stat-label">CORE FREQ</span>
<span class="stat-value" id="stat-core-freq">58.0 Hz</span>
</div>
</div>
</div>
<!-- Master Transport & Actions -->
<div class="transport-bar">
<button class="btn-lcars-large" id="btn-master-play">
<span id="play-icon">▶</span> <span id="play-text">ENGAGE</span>
</button>
<div class="pill-button-group" id="universe-events-container">
<!-- Dynamically populated per universe -->
</div>
<div class="watch-experience-wrap">
<button class="btn-lcars-pill btn-observation" id="btn-observation" title="Start the full-screen procedural visual and sound experience">WATCH EXPERIENCE</button>
<div class="watch-experience-help">Full-screen procedural visuals, soundscape, telemetry, events, and optional local AI announcements.</div>
</div>
<label class="observation-hud-control" title="Toggle starship viewport window frame (ON: framed starship lounge / OFF: pure deep space)">
<span class="observation-hud-label">VIEWPORT</span>
<input type="checkbox" id="toggle-observation-viewport" checked aria-label="Toggle Observation Viewport Frame">
<span class="observation-hud-state" id="val-observation-viewport">ON</span>
</label>
<label class="observation-hud-control" title="Toggle vertical structural window pillars / mullions (OFF: unobstructed panoramic view / ON: structural posts)">
<span class="observation-hud-label">PILLARS</span>
<input type="checkbox" id="toggle-observation-pillars" aria-label="Toggle Window Pillars">
<span class="observation-hud-state" id="val-observation-pillars">OFF</span>
</label>
<div class="observation-activity-control" title="Controls how frequently and intensely Observation generates transient visual activity">
<span class="observation-activity-label">ACTIVITY</span>
<input type="range" id="slider-observation-activity" min="0" max="100" step="1" value="60" aria-label="Observation activity level">
<span class="observation-activity-value" id="val-observation-activity">60%</span>
</div>
<label class="observation-hud-control" title="Keep OBSERVATION profile/status information visible. Turn off to fade it out over 30 seconds.">
<span class="observation-hud-label">HUD HOLD</span>
<input type="checkbox" id="toggle-observation-hud" checked aria-label="Keep Observation information visible">
<span class="observation-hud-state" id="val-observation-hud">ON</span>
</label>
</div>
<!-- Dedicated Master Volume Console -->
<div class="master-volume-console">
<div class="master-vol-top">
<div class="master-vol-title-group">
<span class="master-vol-tag">MAIN MASTER VOLUME</span>
<span class="master-vol-readout" id="val-master-vol">75%</span>
<span class="master-vol-db" id="val-master-db">-2.5 dB</span>
</div>
<div class="master-vol-actions">
<button class="btn-lcars-pill btn-mute" id="btn-master-mute" title="Toggle Mute (M)">MUTE</button>
<div class="master-vol-presets">
<button class="btn-vol-step" data-vol="0.25">25%</button>
<button class="btn-vol-step" data-vol="0.50">50%</button>
<button class="btn-vol-step active" data-vol="0.75">75%</button>
<button class="btn-vol-step" data-vol="1.00">MAX</button>
</div>
</div>
</div>
<div class="master-slider-row">
<button class="vol-adj-btn" id="btn-vol-down" title="Volume Down (-5% or Down Arrow)">−</button>
<div class="slider-track-container">
<input type="range" id="slider-master-vol" min="0" max="1" step="0.01" value="0.75" class="slider-master-large" aria-label="Main Volume">
<!-- Visual 10-Segment LED Level Meter -->
<div class="vol-meter-leds" id="vol-meter-leds">
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip active"></div>
<div class="led-pip"></div>
<div class="led-pip"></div>
</div>
</div>
<button class="vol-adj-btn" id="btn-vol-up" title="Volume Up (+5% or Up Arrow)">+</button>
</div>
<!-- Futuristic Noice Cancellation: deterministic comfort filtering of the three ambience beds -->
<div class="fnc-console" id="fnc-console">
<div class="fnc-top">
<div class="fnc-title-group">
<span class="fnc-title">FNC // FUTURISTIC NOICE CANCELLATION</span>
<span class="fnc-state" id="fnc-state">MONITORING</span>
</div>
<button class="btn-lcars-pill btn-fnc enabled" id="btn-fnc-enable" aria-pressed="true" title="Enable or bypass Futuristic Noice Cancellation">FNC ENABLED</button>
</div>
<div class="fnc-level-row">
<span class="fnc-level-label">FNC LEVEL</span>
<input type="range" id="slider-fnc-level" min="0" max="1" step="0.01" value="0.65" aria-label="Futuristic Noice Cancellation level">
<span class="fnc-level-value" id="val-fnc-level">65%</span>
</div>
<div class="fnc-detail" id="fnc-detail">Comfort suppression active — Life Support, Hull Drone, and Worp Core are reduced according to FNC level.</div>
</div>
</div>
</div>
<!-- Real-Time Frequency Spectrum Visualizer -->
<div class="panel-card">
<div class="panel-header">
<span class="panel-title">ACOUSTIC FREQUENCY SPECTRUM</span>
<span class="panel-sub">PROCEDURAL HARMONIC ANALYSIS</span>
</div>
<div class="canvas-wrapper spectrum-wrapper">
<div class="canvas-label-overlay">SPECTRUM BIN: 32 CH // REAL-TIME FFT</div>
<canvas id="spectrum-canvas"></canvas>
</div>
</div>
<!-- 4-Channel Synthesizer Mixer Card -->
<div class="panel-card">
<div class="panel-header">
<span class="panel-title">PROCEDURAL SOUND ENGINE MIXER</span>
<span class="panel-sub">LIVE PARAMETER MODULATION</span>
</div>
<div class="mixer-grid">
<!-- Channel 1: Hull Vibration -->
<div class="channel-strip">
<div class="channel-header">
<span>1. HULL DRONE</span>
<span id="val-hull-vol">65%</span>
</div>
<div class="control-row">
<div class="control-label"><span>LEVEL</span></div>
<input type="range" id="slider-hull-vol" min="0" max="1" step="0.01" value="0.65">
</div>
<div class="control-row">
<div class="control-label"><span>BASE TONE</span><span id="val-hull-freq">50 Hz</span></div>
<input type="range" id="slider-hull-freq" min="30" max="120" step="1" value="50">
</div>
<div class="control-row">
<div class="control-label"><span>DAMPING CUTOFF</span><span id="val-hull-cutoff">105 Hz</span></div>
<input type="range" id="slider-hull-cutoff" min="50" max="300" step="5" value="105">
</div>
</div>
<!-- Channel 2: Worp Core Pulse -->
<div class="channel-strip">
<div class="channel-header">
<span>2. WORP CORE</span>
<span id="val-warp-vol">35%</span>
</div>
<div class="control-row">
<div class="control-label"><span>LEVEL</span></div>
<input type="range" id="slider-warp-vol" min="0" max="1" step="0.01" value="0.35">
</div>
<div class="control-row">
<div class="control-label"><span>PULSE RATE (BPM)</span><span id="val-warp-bpm">48 BPM</span></div>
<input type="range" id="slider-warp-bpm" min="20" max="120" step="1" value="48">
</div>
<div class="control-row">
<div class="control-label"><span>REACTOR PITCH</span><span id="val-warp-carrier">58 Hz</span></div>
<input type="range" id="slider-warp-carrier" min="35" max="150" step="1" value="58">
</div>
</div>
<!-- Channel 3: Life Support -->
<div class="channel-strip">
<div class="channel-header">
<span>3. LIFE SUPPORT</span>
<span id="val-air-vol">55%</span>
</div>
<div class="control-row">
<div class="control-label"><span>LEVEL</span></div>
<input type="range" id="slider-air-vol" min="0" max="1" step="0.01" value="0.55">
</div>
<div class="control-row">
<div class="control-label"><span>AIRFLOW AIR FILTER</span><span id="val-air-cutoff">1600 Hz</span></div>
<input type="range" id="slider-air-cutoff" min="500" max="4000" step="50" value="1600">
</div>
</div>
<!-- Channel 4: Telemetry -->
<div class="channel-strip">
<div class="channel-header">
<span>4. TELEMETRY</span>
<span id="val-telemetry-vol">45%</span>
</div>
<div class="control-row">
<div class="control-label"><span>LEVEL</span></div>
<input type="range" id="slider-telemetry-vol" min="0" max="1" step="0.01" value="0.45">
</div>
<div class="control-row">
<div class="control-label"><span>BACKGROUND CHIRP DENSITY</span><span id="val-telemetry-density">65%</span></div>
<input type="range" id="slider-telemetry-density" min="0" max="1" step="0.05" value="0.65">
</div>
</div>
</div>
</div>
</section>
<!-- Right Sidebar: Worp Core Intermix Chamber & LCARD Soundboard -->
<aside class="right-column">
<!-- Core / Rotor Visualizer Card -->
<div class="panel-card">
<div class="panel-header">
<span class="panel-title" id="core-visualizer-title">MATTER / ANTIMATTER CORE</span>
</div>
<div class="canvas-wrapper warp-core-wrapper">
<div class="canvas-label-overlay" id="core-visualizer-label">INTERMIX CHAMBER // ACTIVE</div>
<canvas id="warp-core-canvas"></canvas>
</div>
</div>
<!-- XZBT Generative Experience / WebLLM + Kokoro. Optional, network-gated: see agents.md. -->
<div class="panel-card ai-experience-card" id="ai-experience-card">
<div class="panel-header">
<span class="panel-title">GENERATIVE EXPERIENCE</span>
<span class="panel-sub">LOCAL WEBLLM + KOKORO TTS</span>
</div>
<div class="ai-status-line">
<span>LOCAL GENERATIVE SYSTEM</span>
<span class="ai-status-badge" id="ai-status-badge">STANDBY</span>
</div>
<div class="ai-fixed-model">
<b>LANGUAGE MODEL</b><br>
<span id="ai-model-fixed">Qwen3-1.7B-q4f16_1-MLC</span><br>
<small>Automatically prepared when the experience starts. No model selection required.</small>
</div>
<div class="ai-engine-row" style="margin-top:7px">
<div class="ai-engine-chip">WEBLLM <span id="ai-llm-state">OFF</span></div>
<div class="ai-engine-chip">KOKORO <span id="ai-tts-state">OFF</span></div>
</div>
<div class="ai-control-grid">
<button class="btn-lcars-pill" id="btn-ai-prepare">PREPARE AI</button>
<button class="btn-lcars-pill" id="btn-ai-generate" disabled>GENERATE NOW</button>
<label class="ai-toggle-row"><input type="checkbox" id="ai-auto-enabled" checked> AUTO UPDATES</label>
<label class="ai-toggle-row"><input type="checkbox" id="ai-speech-enabled" checked> SPEAK</label>
<select class="span-2" id="ai-kokoro-voice" aria-label="Kokoro voice">
<option value="af_heart">Kokoro voice loads on preparation</option>
</select>
<div class="span-2">
<div class="control-label"><span>VOICE AUTOMATION</span><span id="ai-robot-value">84%</span></div>
<input type="range" id="ai-robot-amount" min="0" max="1" step="0.01" value="0.84" aria-label="Robotic voice effect intensity">
</div>
<div class="span-2">
<div class="control-label"><span>AUTO CADENCE</span><span id="ai-cadence-value">90-180 SEC</span></div>
<input type="range" id="ai-cadence" min="30" max="240" step="15" value="90">
</div>
</div>
<div class="ai-progress" id="ai-progress">AI and neural voice models load only when needed and are cached by the browser when supported.</div>
<div class="ai-experience-output" id="ai-experience-output">GENERATIVE CHANNEL STANDBY</div>
</div>
<!-- TV Presentation -->
<div class="panel-card tv-mode-card" id="tv-mode-card">
<div class="panel-header">
<span class="panel-title">TV PRESENTATION</span>
<span class="panel-sub">CAST-FRIENDLY 16:9</span>
</div>
<label class="ai-toggle-row"><input type="checkbox" id="tv-aspect-lock" checked> LOCK EXPERIENCE TO 16:9</label>
<div class="control-label"><span>RENDER DENSITY</span><span id="tv-quality-value">1080P TARGET</span></div>
<select id="tv-quality">
<option value="0.667">720P PERFORMANCE</option>
<option value="1" selected>1080P STANDARD</option>
<option value="1.333">1440P HIGH</option>
<option value="2">2160P ULTRA</option>
</select>
<div class="ai-progress">The scene keeps a canonical 16:9 layout on phones, PCs, and cast displays. Chrome still negotiates the final Cast stream resolution.</div>
</div>
<!-- Sleep Timer & Auto-Fadeout Card -->
<div class="panel-card">
<div class="panel-header">
<span class="panel-title">SLEEP TIMER</span>
</div>
<div class="timer-readout" id="timer-display">TIMER INACTIVE (CONTINUOUS)</div>
<div class="pill-button-group">
<button class="btn-lcars-pill btn-timer" data-minutes="15">15M</button>
<button class="btn-lcars-pill btn-timer" data-minutes="30">30M</button>
<button class="btn-lcars-pill btn-timer" data-minutes="60">60M</button>
<button class="btn-lcars-pill btn-timer" data-minutes="0">OFF</button>
</div>
</div>
<!-- Tactical Soundboard & Console Keypad -->
<div class="panel-card">
<div class="panel-header">
<span class="panel-title" id="soundboard-title">LCARD KEYPAD</span>
</div>
<div class="telemetry-triggers" id="soundboard-container">
<!-- Dynamically populated per universe -->
</div>
</div>
</aside>
</main>
<!-- OBSERVATION: passive casting / display mode. Click background to return. -->
<section class="observation-overlay" id="observation-overlay" aria-hidden="true" aria-label="Watch Experience full-screen display">
<!-- Layer 0 & 1: 60fps Deep Celestial Canvas (Parallax 3D Stars, Warp Streaks, Nebulae, Planets, Encounters) -->
<canvas id="observation-canvas" class="observation-canvas"></canvas>
<!-- Legacy / Vector Art Stage (Preserved for compatibility and vector overlays) -->
<div class="observation-stage" id="observation-stage"></div>
<div class="observation-sim-layer" id="observation-sim-layer"></div>
<div class="observation-event-layer" id="observation-event-layer"></div>
<!-- Layer 2: Viewport Architecture & Glass (Starship Window Frames & Specular Sheen) -->
<div class="observation-viewport-frame" id="observation-viewport-frame"></div>
<div class="observation-glass-sheen"></div>
<div class="observation-scanlines"></div>
<div class="observation-vignette"></div>
<!-- Layer 3: Emergency Alert Environmental Lighting Wash -->
<div class="observation-alert-wash" id="observation-alert-wash"></div>
<!-- Layer 4: Holographic LCARD HUD & Audio Waveform Sill -->
<div class="observation-chrome">
<div class="observation-ticker" id="observation-ticker">
<span class="observation-ticker-label">SYS</span>
<div class="observation-ticker-track">
<span class="observation-ticker-text" id="observation-ticker-text">STANDBY</span>
</div>
</div>
<div class="observation-topline">
<div>
<div class="observation-mode-label" id="observation-universe">OBSERVATION</div>
<div class="observation-profile" id="observation-profile">ACTIVE PROFILE</div>
</div>
<div class="observation-status" id="observation-status">
OBSERVATION ACTIVE<br>
PROCEDURAL AUDIO LINK: STANDBY
</div>
</div>
<!-- Holographic Audio Waveform Sill -->
<div class="observation-waveform-container" id="observation-waveform-container">
<span class="observation-waveform-label">SUBSPACE HARMONICS</span>
<canvas id="observation-waveform-canvas" width="480" height="28"></canvas>
</div>
</div>
<!-- In-Observation Quick Glass Control Dock (Auto-hides on inactivity) -->
<div class="observation-control-dock" id="observation-control-dock">
<button class="obs-dock-btn" id="obs-btn-warp" title="Toggle Warp Flight / Orbital Cruise">WARP: OFF</button>
<button class="obs-dock-btn active" id="obs-btn-frame" title="Toggle Starship Viewport Framing">VIEWPORT: ON</button>
<button class="obs-dock-btn" id="obs-btn-pillars" title="Toggle Window Pillars / Mullions">PILLARS: OFF</button>
<button class="obs-dock-btn" id="obs-btn-alert" title="Toggle Red Alert">RED ALERT</button>
<select class="obs-dock-select" id="obs-select-preset" title="Switch Preset"></select>
<button class="obs-dock-btn obs-dock-close" id="obs-btn-exit" title="Return to Main Console">RETURN ✕</button>
</div>
<div class="observation-return" id="observation-return-pill">CLICK BACKGROUND TO RETURN</div>
</section>
<!-- LCARD Footer Status -->
<footer class="lcars-footer">
<span id="footer-status-label">SYSTEM STATUS: NORMAL</span>
<span><a href="https://www.labyricorn.com/" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none;">VIBE ENGINEERED BY LABYRICORN STUDIOS</a></span>
<span id="footer-hotkeys-label">SPACE: PLAY/PAUSE | M: MUTE | ↑/↓: VOLUME</span>
</footer>
<!-- All-in-One Procedural Audio Engine & UI -->
<script src="js/audio.js"></script>
<script src="js/config.js"></script>
<script src="js/core-animations.js"></script>
<script src="js/visualizer.js"></script>
<script src="js/observation-bezels.js"></script>
<script src="js/observation-engine.js"></script>
<script src="js/control-bus.js"></script>
<script src="js/generative-experience.js"></script>
<script src="js/contract-adapter.js"></script>
<script src="js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
/**
* XZBT Control Bus
*
* XZBTControlBus is a small registry of named, settable console targets
* (ranges, toggles and triggers). Anything that wants to drive the console
* from outside the DOM — contract adapter, hotkeys, future transports —
* talks to the bus rather than reaching into app.js internals.
*
* MIDI NOTE: Web MIDI control belonged to standalone SciFi-XZBT until
* Step 3.5. External MIDI integration will be handled by XZBT-NGN, which
* will send canonical contract commands (set / invoke) to this exhibit.
* The control bus itself is transport-agnostic and is intentionally kept here.
*/
class XZBTControlBus {
constructor() {
this.targets = new Map();
this.listeners = new Set();
}
register(id, spec) {
this.targets.set(id, { id, ...spec });
}
set(id, value, source = 'external') {
const t = this.targets.get(id);
if (!t) return false;
let v = value;
if (t.type === 'range') {
v = Math.max(t.min, Math.min(t.max, Number(value)));
if (t.step) v = Math.round(v / t.step) * t.step;
}
if (t.apply) t.apply(v, source);
this.listeners.forEach(fn => fn({ id, value: v, source, target: t }));
return true;
}
trigger(id, source = 'external') {
const t = this.targets.get(id);
if (!t) return false;
if (t.apply) t.apply(true, source);
this.listeners.forEach(fn => fn({ id, value: true, source, target: t }));
return true;
}
list() {
return [...this.targets.values()];
}
}
window.XZBTControlBus = XZBTControlBus;
@@ -0,0 +1,216 @@
// Approved settings-panel core animations. Procedural, offline, and dependency-free.
(() => {
'use strict';
const TAU = Math.PI * 2;
function line(c, points, color, width = 1) {
c.beginPath(); points.forEach(([x, y], i) => i ? c.lineTo(x, y) : c.moveTo(x, y));
c.strokeStyle = color; c.lineWidth = width; c.stroke();
}
function ring(c, x, y, r, color, width = 1, start = 0, end = TAU) {
c.beginPath(); c.arc(x, y, r, start, end); c.strokeStyle = color; c.lineWidth = width; c.stroke();
}
function dot(c, x, y, r, color) {
c.fillStyle = color; c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill();
}
function glow(c, x, y, r, color, alpha = 1) {
c.save(); c.globalAlpha = alpha;
const g = c.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, color); g.addColorStop(1, color + '00');
c.fillStyle = g; c.fillRect(x - r, y - r, r * 2, r * 2); c.restore();
}
function rect(c, x, y, w, h, fill, stroke) {
c.fillStyle = fill; c.fillRect(x, y, w, h);
if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1; c.strokeRect(x, y, w, h); }
}
function polygon(c, r, sides, color, rotation = 0, width = 1) {
const p = Array.from({ length: sides + 1 }, (_, i) => [Math.cos(i * TAU / sides + rotation) * r, Math.sin(i * TAU / sides + rotation) * r]);
line(c, p, color, width);
}
function base(c) {
for (let x = -160; x <= 160; x += 20) for (let y = -120; y <= 120; y += 20) dot(c, x, y, 0.6, '#253545');
line(c, [[-158, -93], [-158, -110], [-141, -110]], '#344556');
line(c, [[158, 93], [158, 110], [141, 110]], '#344556');
}
const renderers = {
starfleet(c, t, load) {
const beat = Math.pow((Math.sin(t * 3.4) + 1) / 2, 5);
glow(c, 0, 0, 92, '#167eca', 0.17 + load * 0.2);
rect(c, -39, -105, 78, 210, '#0b1b2b', '#335770');
for (const side of [-1, 1]) {
line(c, [[side * 34, -105], [side * 48, -78], [side * 48, 78], [side * 34, 105]], '#6f8d9c', 3);
rect(c, -37, side < 0 ? -110 : 102, 74, 8, '#b89976');
line(c, [[side * 28, 0], [side * 87, 0], [side * 106, 20], [side * 146, 20]], '#234253', 8);
line(c, [[side * 28, 0], [side * 87, 0], [side * 106, 20], [side * 146, 20]], '#80b4c4', 1);
for (let i = 0; i < 18; i++) {
const travel = (i / 18 + t * 0.55) % 1;
const y = side * (100 - travel * 97);
const x = Math.sin(travel * 13 + t * 2) * 12;
line(c, [[x, y + side * 9], [x, y]], side < 0 ? '#75baff' : '#c8f5ff', 1 + load * 2);
}
}
for (let i = -6; i <= 6; i++) {
const wave = Math.pow(Math.max(0, Math.cos(t * 3.4 - Math.abs(i) * 0.62)), 6);
c.save(); c.globalAlpha = 0.2 + wave * (0.4 + load * 0.4);
line(c, [[-32, i * 15], [-22, i * 15 + 3], [22, i * 15 + 3], [32, i * 15]], '#89eaff', 3); c.restore();
}
glow(c, 0, 0, 28 + beat * 18 * load, '#81ddff', 0.8);
line(c, [[0, -20], [14, 0], [0, 20], [-14, 0], [0, -20]], '#e8fdff', 2);
line(c, [[0, -20], [0, 20], [14, 0], [-14, 0]], '#b0eeff');
},
military(c, t, load) {
polygon(c, 104, 6, '#3f4a50', Math.PI / 6, 12);
polygon(c, 110, 6, '#a7987e', Math.PI / 6);
for (let i = 0; i < 6; i++) {
const a = i * TAU / 6 + Math.PI / 6;
dot(c, Math.cos(a) * 104, Math.sin(a) * 104, 3, '#d6c6ad');
c.save(); c.rotate(a);
rect(c, 38, -10, 38, 20, '#171f25', '#655e4d');
for (let j = 0; j < 5; j++) rect(c, 42 + j * 6, -6, 4, 12, j < 1 + load * 4 && (t * 2 + i) % 6 > j * 0.65 ? '#e8b468' : '#433e30');
c.restore();
}
for (const [r, direction, color] of [[86, 1, '#d3b285'], [31, -1, '#8ea8a6']]) {
for (let i = 0; i < 12; i++) {
const a = i * TAU / 12 + direction * t * 0.65;
ring(c, 0, 0, r, color, 4, a, a + 0.3);
}
}
glow(c, 0, 0, 29, '#eab164', 0.25 + load * 0.4);
polygon(c, 18, 6, '#f8d09d', 0, 2);
line(c, [[-10, 0], [10, 0]], '#e7d8bf', 3);
for (const x of [-136, 126]) for (let j = 0; j < 7; j++) rect(c, x, -38 + j * 12, 10, 7, j < load * 7 ? '#c7a16c' : '#263039');
},
outlaw(c, t, load) {
c.save(); c.translate(Math.sin(t * 19) * load * 1.4, Math.sin(t * 23) * load * 1.5);
line(c, [[-126, 50], [-93, 50], [-93, -40], [26, -40], [26, -19]], '#a26c4f', 5);
line(c, [[-77, 48], [-54, 66], [47, 66], [66, 29]], '#746554', 4);
rect(c, -103, -23, 40, 70, '#302d29', '#81705c');
rect(c, -74, -38, 100, 85, '#34383b', '#8b8c7b');
rect(c, -53, -45, 29, 17, '#674938', '#c28b58');
rect(c, -19, -21, 40, 48, '#233c39', '#609a87');
for (let i = 0; i < 4; i++) rect(c, -14, -15 + i * 9, 28, 4, '#79b49e');
rect(c, 27, -22, 35, 51, '#494139', '#a08c6e');
for (let i = 0; i < 4; i++) rect(c, 30 + i * 8, -27, 4, 61, '#80745f');
const flicker = 0.8 + Math.sin(t * 17) * 0.12 + Math.sin(t * 29) * 0.08;
c.save(); c.translate(68, 3); c.scale((0.2 + load * 0.85) * flicker, 0.4 + load * 0.5);
glow(c, 25, 0, 55, '#e88250', 0.5);
for (let j = 0; j < 7; j++) {
const len = 42 + 35 * Math.sin(t * 13 + j) ** 2;
line(c, [[0, (j - 3) * 4], [len * 0.5, (j - 3) * 3], [len, Math.sin(t * 8 + j) * 5]], j % 2 ? '#ffc07c' : '#a3e5e7', 2);
}
c.restore();
dot(c, -67, 2, 27, '#0b1015'); ring(c, -67, 2, 28, '#b0967a', 3);
c.save(); c.translate(-67, 2); c.rotate(t * 4);
for (let i = 0; i < 5; i++) { c.rotate(TAU / 5); line(c, [[5, 0], [20, -8], [22, 3], [5, 0]], '#a49c87', 3); }
c.restore(); dot(c, -67, 2, 6, '#d8ae7a');
c.font = '9px Consolas, monospace'; c.fillStyle = '#dcc1a1'; c.fillText('NO. 07', -15, 43);
c.restore();
},
comedy(c, t, load) {
c.translate(0, Math.sin(t * 1.2) * 8);
glow(c, 0, 0, 100, '#6b49af', 0.17);
const colors = ['#d0acff', '#81ded2', '#f0c78e'];
for (let i = 0; i < 3; i++) {
c.save(); c.rotate(i * Math.PI / 3 + Math.sin(t * 0.5 + i) * 0.3);
const r = 74 + load * 15;
const squeeze = 0.22 + Math.abs(Math.sin(t * 0.65 + i)) * 0.55;
c.scale(1, squeeze); ring(c, 0, 0, r, colors[i], 2);
const a = t * (0.7 + i * 0.2) + i * 2;
dot(c, Math.cos(a) * r, Math.sin(a) * r, 5, colors[i]); c.restore();
}
glow(c, 0, 0, 45, '#a686f4', 0.4);
const vertices = Array.from({ length: 6 }, (_, i) => {
const a = i * TAU / 6 + t * 0.3;
const r = 24 + Math.sin(t * 1.7 + i * 2) * (4 + load * 10);
return [Math.cos(a) * r, Math.sin(a) * r];
});
line(c, [...vertices, vertices[0]], '#ece0ff', 2);
for (let i = 0; i < 6; i++) { line(c, [vertices[i], vertices[(i + 2) % 6]], '#b6a4d9'); dot(c, ...vertices[i], 2, '#ffffff'); }
for (let i = 0; i < 7; i++) {
const a = i * 2.4 + t * 0.18;
const x = Math.cos(a) * 121, y = Math.sin(a) * 86;
line(c, [[x - 3, y], [x + 3, y]], colors[i % 3]); line(c, [[x, y - 3], [x, y + 3]], colors[i % 3]);
}
},
industrial(c, t, load) {
const compression = (Math.sin(t * 2) + 1) / 2;
rect(c, -70, -93, 140, 186, '#27241e', '#877653');
for (const y of [-94, 85]) for (let i = 0; i < 14; i++) rect(c, -70 + i * 10, y, 10, 9, i % 2 ? '#bd943f' : '#30291a');
rect(c, -54, -76, 108, 152, '#0d1011', '#5e543d');
glow(c, 0, 0, 60 + compression * load * 18, '#ec8c26', 0.6 + load * 0.3);
c.save(); c.scale(1 - compression * 0.25, 1 + compression * 0.13);
glow(c, 0, 0, 31 + load * 8, '#ffda87'); c.restore();
for (const side of [-1, 1]) {
const x = side < 0 ? -133 : 78;
rect(c, x, -21, 55, 42, '#2e3332', '#6e7770');
const reach = 25 + compression * (12 + load * 16);
const inner = side * (77 - reach);
rect(c, side < 0 ? -78 : inner, -8, reach, 16, '#a6a092', '#d1c6ac');
rect(c, inner - (side < 0 ? 0 : 8), -34, 8, 68, '#746447', '#d9b875');
}
for (let i = 0; i < 16; i++) {
const travel = (i / 16 + t * 0.24) % 1;
dot(c, Math.sin(i * 8 + travel * 4) * 36, 60 - travel * 125, 0.8 + (i % 3) * 0.5, '#e4ad61');
}
for (const y of [-65, 59]) for (let i = 0; i < 8; i++) rect(c, -45 + i * 12, y, 6, 6, i < 2 + load * 6 ? '#e4a751' : '#55422b');
for (const x of [-63, 63]) for (const y of [-80, 80]) dot(c, x, y, 3, '#ad9b77');
},
station(c, t, load) {
ring(c, 0, 0, 109, '#34545c');
for (let i = 0; i < 4; i++) {
c.save(); c.rotate(i * Math.PI / 2);
line(c, [[-15, -119], [-15, -106], [15, -106], [15, -119]], '#8ba9a9', 3); c.restore();
}
c.save(); c.rotate(t * 0.24);
ring(c, 0, 0, 89, '#263f44', 19); ring(c, 0, 0, 100, '#93bcb8', 2); ring(c, 0, 0, 78, '#537c7e', 2);
for (let i = 0; i < 24; i++) {
const a = i * TAU / 24;
ring(c, 0, 0, 90, i % 4 === 0 ? '#c4d5bd' : '#568d86', 8, a + 0.025, a + 0.16);
}
for (let i = 0; i < 6; i++) {
c.save(); c.rotate(i * TAU / 6);
line(c, [[19, -4], [78, -4]], '#5b797c', 2); line(c, [[19, 4], [78, 4]], '#5b797c', 2);
for (let j = 0; j < 1 + Math.floor(load * 3); j++) {
const travel = (t * 0.55 + j / 3 + i / 6) % 1;
dot(c, 22 + travel * 55, 0, 2, '#b4ffe0');
}
c.restore();
}
c.restore();
glow(c, 0, 0, 30, '#73e4c2', 0.3 + load * 0.3);
dot(c, 0, 0, 21, '#142d32'); ring(c, 0, 0, 21, '#a3d9cd', 2);
line(c, [[-10, -5], [10, -5], [10, 5], [-10, 5], [-10, -5]], '#c5e7dc', 2);
for (let i = 0; i < 6; i++) ring(c, 0, 0, 115, '#3b625f', 2, i * TAU / 6 + 0.3, i * TAU / 6 + 0.6);
}
};
const modes = {
'intermix-cascade': 'starfleet',
'tactical-flywheel': 'military',
'patchwork-drive': 'outlaw',
'improbability-engine': 'comedy',
'compression-furnace': 'industrial',
'station-hub': 'station'
};
const CoreAnimations = {
has(mode) { return Object.prototype.hasOwnProperty.call(modes, mode); },
render(ctx, mode, width, height, time, load) {
if (!this.has(mode) || width <= 20 || height <= 42) return;
ctx.clearRect(0, 0, width, height);
ctx.save();
// Preserve the existing label and the full motion envelope at sidebar size.
const labelSpace = 32;
const bottomSpace = 10;
ctx.translate(width / 2, labelSpace + (height - labelSpace - bottomSpace) / 2);
const scale = Math.min((width - 20) / 330, (height - labelSpace - bottomSpace) / 250);
ctx.scale(scale, scale);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
base(ctx);
renderers[modes[mode]](ctx, time, Math.max(0, Math.min(1, load)));
ctx.restore();
}
};
window.CoreAnimations = CoreAnimations;
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
/**
* Procedural SVG Viewport Frames & Bezels for Observation Mode
*/
window.ObservationBezels = {
getViewportFrameSvg(universeId, presetId, engine) {
if (universeId === 'spacestations') {
// Space Stations has its own authentic native station viewport in observationStage!
return '';
}
if (universeId === 'starfleet') {
let era = 'tng';
const preset = (typeof StarshipPresets !== 'undefined' && presetId) ? StarshipPresets[presetId] : null;
if (preset && preset.era) era = preset.era;
if (era === 'tos') {
// TOS Original Series - hexagonal bridge viewscreen bezel
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="tosMetal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b2118"/>
<stop offset="50%" stop-color="#171310"/>
<stop offset="100%" stop-color="#0a0806"/>
</linearGradient>
</defs>
<path d="M0,0 H1600 V70 L1350,190 H250 L0,70 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
<path d="M0,900 H1600 V830 L1350,710 H250 L0,830 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
<path d="M0,0 V900 H130 L170,710 V190 L130,0 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
<path d="M1600,0 V900 H1470 L1430,710 V190 L1470,0 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
${(engine ? engine.showPillars : false) ? `
<rect x="530" y="195" width="30" height="515" fill="#171310" stroke="#dc2626" stroke-width="2.5" opacity="0.9"/>
<rect x="1040" y="195" width="30" height="515" fill="#171310" stroke="#dc2626" stroke-width="2.5" opacity="0.9"/>
` : ''}
<text x="800" y="865" text-anchor="middle" fill="#f59e0b" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">U.S.S. INTERPRISE NCC-1701.5 // BRIDGE VIEWSCREEN</text>
</svg>
`;
}
if (era === 'voyager') {
// Wayfarer Intrepid Class - sleeker rounded modern arch, cyan accent
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="voyMetal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0f2027"/>
<stop offset="50%" stop-color="#0a1418"/>
<stop offset="100%" stop-color="#050a0c"/>
</linearGradient>
</defs>
<path d="M0,0 H1600 V70 Q800,130 0,70 Z" fill="url(#voyMetal)" stroke="#22d3ee" stroke-width="3"/>
<path d="M0,900 H1600 V830 Q800,770 0,830 Z" fill="url(#voyMetal)" stroke="#22d3ee" stroke-width="3"/>
<path d="M0,0 V900 H80 Q110,450 80,0 Z" fill="url(#voyMetal)"/>
<path d="M1600,0 V900 H1520 Q1490,450 1520,0 Z" fill="url(#voyMetal)"/>
${(engine ? engine.showPillars : false) ? `
<path d="M525,90 L535,800 L515,800 L505,90 Z" fill="#0a1418" stroke="#22d3ee" stroke-width="2" opacity="0.9"/>
<path d="M1075,90 L1065,800 L1085,800 L1095,90 Z" fill="#0a1418" stroke="#22d3ee" stroke-width="2" opacity="0.9"/>
` : ''}
<g transform="translate(500, 838)">
<rect x="0" y="0" width="600" height="20" rx="10" fill="#22d3ee" opacity="0.85"/>
<text x="300" y="15" text-anchor="middle" fill="#000" font-family="'Antonio', sans-serif" font-weight="700" font-size="12" letter-spacing="3">USS WAYFARER // BRIDGE VIEWPORT</text>
</g>
</svg>
`;
}
if (era === 'ds9') {
// Defiance Class / DS9 Ops - angular, aggressive warship framing
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="defMetal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1c1410"/>
<stop offset="50%" stop-color="#0c0806"/>
<stop offset="100%" stop-color="#1c1410"/>
</linearGradient>
</defs>
<path d="M0,0 H1600 V60 L1300,170 H300 L0,60 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
<path d="M0,900 H1600 V840 L1300,730 H300 L0,840 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
<path d="M0,0 V900 H150 L190,730 V170 L150,0 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
<path d="M1600,0 V900 H1450 L1410,730 V170 L1450,0 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
<text x="800" y="865" text-anchor="middle" fill="#f97316" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">USS DEFIANCE // TACTICAL BRIDGE VIEWPORT</text>
</svg>
`;
}
if (era === 'nx') {
// Interprise NS-01 - early, industrial, submarine-like bulkhead
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="nxMetal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1c1917"/>
<stop offset="50%" stop-color="#0c0a09"/>
<stop offset="100%" stop-color="#1c1917"/>
</linearGradient>
</defs>
<path d="M0,0 H1600 V110 L1420,175 H180 L0,110 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
<path d="M0,900 H1600 V790 L1420,725 H180 L0,790 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
<path d="M0,0 V900 H120 L150,725 V175 L120,0 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
<path d="M1600,0 V900 H1480 L1450,725 V175 L1480,0 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
<text x="800" y="865" text-anchor="middle" fill="#a16207" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">INTERPRISE NS-01 // COMMAND BRIDGE VIEWPORT</text>
</svg>
`;
}
// era === 'tng' (or unrecognized) falls through to the default Ten Forward arch below.
}
if (universeId === 'industrial' || universeId === 'outlaw') {
// Heavy Industrial Reinforced Bulkhead & Riveted Blast Shutter Framing
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="indMetal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1c1917"/>
<stop offset="50%" stop-color="#0c0a09"/>
<stop offset="100%" stop-color="#1c1917"/>
</linearGradient>
</defs>
<!-- Outer Reinforced Rim -->
<path d="M0,0 H1600 V100 L1460,160 H140 L0,100 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
<path d="M0,900 H1600 V800 L1460,740 H140 L0,800 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
<path d="M0,0 V900 H110 L140,740 V160 L110,0 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
<path d="M1600,0 V900 H1490 L1460,740 V160 L1490,0 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
${(engine ? engine.showPillars : false) ? `
<!-- Vertical Heavy Structural Mullions -->
<rect x="530" y="160" width="34" height="580" fill="#292524" stroke="#78350f" stroke-width="3"/>
<rect x="1036" y="160" width="34" height="580" fill="#292524" stroke="#78350f" stroke-width="3"/>
` : ''}
<!-- Hazard Stripes on Lower Sill -->
<g opacity="0.6">
<rect x="220" y="755" width="1160" height="14" fill="#f59e0b"/>
<path d="M230,755 L250,769 M270,755 L290,769 M310,755 L330,769 M350,755 L370,769 M390,755 L410,769 M430,755 L450,769 M470,755 L490,769 M510,755 L530,769" stroke="#000" stroke-width="6"/>
</g>
<text x="800" y="865" text-anchor="middle" fill="#d97706" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">HEAVY INDUSTRIAL VIEWPORT // DECK 04 CARGO GANTRY</text>
</svg>
`;
}
if (universeId === 'whataverse') {
// Gallifreyan Observatory Roundel Viewing Portal / TARDIX Frame
// Console era (classic/revival/modern) tints the rings and label.
let era = 'revival';
const wUniverse = (typeof UniverseRegistry !== 'undefined') ? UniverseRegistry['whataverse'] : null;
const preset = (wUniverse && wUniverse.presets && presetId) ? wUniverse.presets[presetId] : null;
if (preset && preset.era) era = preset.era;
let ringA = '#d4af37', ringB = '#00e5ff', dash = '16 12', labelColor = '#d4af37';
let label = 'T.A.R.D.I.X. TEMPORAL VORTEX OBSERVATION PORTAL';
if (era === 'classic') {
ringA = '#b45309'; ringB = '#f5deb3'; dash = '22 10'; labelColor = '#f5deb3';
label = 'TYPE 4D CLASSIC CONSOLE // TEMPORAL VIEWPORT';
} else if (era === 'modern') {
ringA = '#e2e8f0'; ringB = '#94a3b8'; dash = '4 6'; labelColor = '#e2e8f0';
label = 'INFINITE WHITE // TEMPORAL OBSERVATION PORTAL';
}
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<!-- Circular Astrolabe Arch Frame -->
<path d="M0,0 H1600 V900 H0 Z M800,450 m-680,0 a680,410 0 1,0 1360,0 a680,410 0 1,0 -1360,0" fill="#030712" fill-rule="evenodd" stroke="${ringB}" stroke-width="4"/>
<!-- Gallifreyan Circular Inscriptions -->
<ellipse cx="800" cy="450" rx="690" ry="420" fill="none" stroke="${ringA}" stroke-width="3" stroke-dasharray="${dash}"/>
<ellipse cx="800" cy="450" rx="715" ry="440" fill="none" stroke="${ringB}" stroke-width="1.5" opacity="0.6"/>
<text x="800" y="875" text-anchor="middle" fill="${labelColor}" font-family="'Share Tech Mono', monospace" font-size="17" letter-spacing="4">${label}</text>
</svg>
`;
}
if (universeId === 'deepspace') {
// Terok Nor / DS9 Arched Station Viewport overlooking Docking Pylons
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0,0 H1600 V120 Q800,190 0,120 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
<path d="M0,900 H1600 V780 Q800,720 0,780 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
<path d="M0,0 V900 H140 L160,780 Q180,450 160,120 L140,0 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
<path d="M1600,0 V900 H1460 L1440,780 Q1420,450 1440,120 L1460,0 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
${(engine ? engine.showPillars : false) ? `
<!-- Exterior Docking Ring Structure visible outside -->
<path d="M180,520 L420,410 L440,430 L180,560 Z" fill="#1e293b" opacity="0.6" stroke="#38bdf8" stroke-width="2"/>
<path d="M1420,520 L1180,410 L1160,430 L1420,560 Z" fill="#1e293b" opacity="0.6" stroke="#38bdf8" stroke-width="2"/>
` : ''}
<text x="800" y="860" text-anchor="middle" fill="#38bdf8" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="3">DEEP SPACE STATION // PROMENADE OBSERVATION DECK</text>
</svg>
`;
}
if (universeId === 'bioships' || universeId === 'retrofuture' || universeId === 'military' || universeId === 'comedy') {
// These four universes previously had no case here at all and silently fell through
// to the Starflight default below, which hardcodes "USS INTERPRISE-G" into the plaque --
// showing the wrong ship name in every one of these themes' Observation view.
// Fix: reuse the same bulkhead shape (it already themes correctly via the CSS
// accent variables set per universe), but pull the REAL ship/deck name for the
// plaque text from this universe's own preset data, same source the header uses.
const universe = UniverseRegistry[universeId];
const preset = (universe && universe.presets && presetId) ? universe.presets[presetId] : null;
const plaqueText = preset
? `${preset.name.toUpperCase()} // OBSERVATION DECK`
: `${universe ? universe.name : universeId.toUpperCase()} // OBSERVATION DECK`;
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="genBulkhead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e222d"/>
<stop offset="50%" stop-color="#10141e"/>
<stop offset="100%" stop-color="#0a0d14"/>
</linearGradient>
</defs>
<path d="M0,0 H1600 V80 Q800,150 0,80 Z" fill="url(#genBulkhead)" stroke="var(--primary-accent, #ff9900)" stroke-width="3"/>
<path d="M0,900 H1600 V810 Q800,740 0,810 Z" fill="url(#genBulkhead)" stroke="var(--secondary-accent, #cc99cc)" stroke-width="3"/>
<path d="M0,0 V900 H90 Q120,450 90,0 Z" fill="url(#genBulkhead)"/>
<path d="M1600,0 V900 H1510 Q1480,450 1510,0 Z" fill="url(#genBulkhead)"/>
${(engine ? engine.showPillars : false) ? `
<path d="M510,95 L535,95 L545,765 L500,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
<path d="M1090,95 L1065,95 L1055,765 L1100,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
` : ''}
<g transform="translate(480, 830)">
<rect x="0" y="0" width="640" height="24" rx="12" fill="var(--primary-accent, #ff9900)" opacity="0.85"/>
<text x="320" y="17" text-anchor="middle" fill="#000" font-family="'Antonio', sans-serif" font-weight="700" font-size="14" letter-spacing="3">${plaqueText}</text>
</g>
</svg>
`;
}
// Default: Iconic Starflight Ten Forward / Galaxy-Class Observation Lounge
return `
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="sfBulkhead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e222d"/>
<stop offset="50%" stop-color="#10141e"/>
<stop offset="100%" stop-color="#0a0d14"/>
</linearGradient>
</defs>
<!-- Top Arched Bulkhead Canopy -->
<path d="M0,0 H1600 V80 Q800,150 0,80 Z" fill="url(#sfBulkhead)" stroke="var(--primary-accent, #ff9900)" stroke-width="3"/>
<!-- Bottom Curved Padded Console Sill -->
<path d="M0,900 H1600 V810 Q800,740 0,810 Z" fill="url(#sfBulkhead)" stroke="var(--secondary-accent, #cc99cc)" stroke-width="3"/>
<!-- Side Bulkheads -->
<path d="M0,0 V900 H90 Q120,450 90,0 Z" fill="url(#sfBulkhead)"/>
<path d="M1600,0 V900 H1510 Q1480,450 1510,0 Z" fill="url(#sfBulkhead)"/>
${(engine ? engine.showPillars : false) ? `
<!-- Vertical Architectural Tapered Mullions dividing into 3 bays -->
<path d="M510,95 L535,95 L545,765 L500,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
<path d="M1090,95 L1065,95 L1055,765 L1100,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
` : ''}
<!-- Authentic LCARD Display Bar along lower sill -->
<g transform="translate(480, 830)">
<rect x="0" y="0" width="640" height="24" rx="12" fill="var(--primary-accent, #ff9900)" opacity="0.85"/>
<text x="320" y="17" text-anchor="middle" fill="#000" font-family="'Antonio', sans-serif" font-weight="700" font-size="14" letter-spacing="3">USS INTERPRISE-G // OBSERVATION LOUNGE // DECK 10 FORWARD</text>
</g>
</svg>
`;
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,673 @@
class StarshipVisualizer {
constructor(audioManager, warpSynth) {
this.am = audioManager;
this.warpSynth = warpSynth;
this.spectrumCanvas = null;
this.spectrumCtx = null;
this.warpCoreCanvas = null;
this.warpCoreCtx = null;
this.animationFrameId = null;
this.pulseEnergy = 0.2;
this.warpParticles = [];
this.mode = 'warp-core'; // 'warp-core' or 'time-rotor'
this.rotorPhase = 0;
this.coreTime = 0;
this.coreLoad = 0.6;
this.lastFrameTime = null;
// Hook into worp core pulse callback
if (this.warpSynth) {
this.warpSynth.onPulse = (phase, duration) => {
this.pulseEnergy = 1.0;
this.spawnWarpPulses();
};
}
}
setMode(mode) {
this.mode = mode || 'warp-core';
}
init(spectrumCanvasId, warpCoreCanvasId) {
this.spectrumCanvas = document.getElementById(spectrumCanvasId);
if (this.spectrumCanvas) {
this.spectrumCtx = this.spectrumCanvas.getContext('2d');
}
this.warpCoreCanvas = document.getElementById(warpCoreCanvasId);
if (this.warpCoreCanvas) {
this.warpCoreCtx = this.warpCoreCanvas.getContext('2d');
this.initWarpParticles();
}
window.addEventListener('resize', () => this.resizeCanvases());
this.resizeCanvases();
this.startRenderLoop();
}
resizeCanvases() {
if (this.spectrumCanvas) {
const rect = this.spectrumCanvas.parentElement.getBoundingClientRect();
this.spectrumCanvas.width = rect.width * window.devicePixelRatio;
this.spectrumCanvas.height = (rect.height || 160) * window.devicePixelRatio;
this.spectrumCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
if (this.warpCoreCanvas) {
const rect = this.warpCoreCanvas.parentElement.getBoundingClientRect();
this.warpCoreCanvas.width = rect.width * window.devicePixelRatio;
this.warpCoreCanvas.height = (rect.height || 260) * window.devicePixelRatio;
this.warpCoreCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
}
initWarpParticles() {
this.warpParticles = [];
for (let i = 0; i < 36; i++) {
this.warpParticles.push({
y: Math.random(),
speed: (Math.random() * 0.008 + 0.004) * (Math.random() > 0.5 ? 1 : -1),
size: Math.random() * 4 + 2,
opacity: Math.random() * 0.7 + 0.3
});
}
}
spawnWarpPulses() {
// The new machines render their own flow; only legacy particle modes need a pool.
if (this.mode !== 'warp-core' && this.mode !== 'time-rotor') return;
for (let i = 0; i < 6; i++) {
this.warpParticles.push({
y: 0.5, // Center matter/antimatter reaction plane
speed: (Math.random() * 0.018 + 0.01) * (i % 2 === 0 ? 1 : -1),
size: Math.random() * 6 + 3,
opacity: 1.0
});
}
}
startRenderLoop() {
this.lastFrameTime = null;
const render = (now) => {
const elapsed = this.lastFrameTime === null ? 0 : Math.min((now - this.lastFrameTime) / 1000, 0.05);
this.lastFrameTime = now;
const volume = this.warpSynth && !this.warpSynth.isMuted ? this.warpSynth.params.volume : 0;
const targetLoad = Math.max(0, Math.min(1, volume * (0.65 + this.pulseEnergy * 0.35)));
this.coreLoad += (targetLoad - this.coreLoad) * (1 - Math.exp(-elapsed * 6));
this.coreTime += elapsed * (this.mode === 'tactical-flywheel' ? 0.6 + this.coreLoad * 0.8 : 1);
this.renderSpectrum();
if (window.CoreAnimations && CoreAnimations.has(this.mode)) {
if (this.warpCoreCanvas && this.warpCoreCtx) {
CoreAnimations.render(this.warpCoreCtx, this.mode,
this.warpCoreCanvas.width / window.devicePixelRatio,
this.warpCoreCanvas.height / window.devicePixelRatio,
this.coreTime, this.coreLoad);
}
} else {
switch (this.mode) {
case 'time-rotor':
this.renderTimeRotor();
break;
case 'industrial-reactor':
this.renderIndustrialReactor();
break;
case 'bio-heart':
this.renderBioHeart();
break;
case 'retro-oscilloscope':
this.renderRetroOscilloscope();
break;
case 'singularity-core':
this.renderSingularityCore();
break;
case 'warp-core':
default:
this.renderWarpCore();
break;
}
}
// Decay pulse energy smoothly
this.pulseEnergy = Math.max(0.15, this.pulseEnergy * Math.pow(0.94, elapsed * 60));
this.animationFrameId = requestAnimationFrame(render);
};
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = requestAnimationFrame(render);
}
renderSpectrum() {
if (!this.spectrumCanvas || !this.spectrumCtx || !this.am.analyser) return;
const ctx = this.spectrumCtx;
const w = this.spectrumCanvas.width / window.devicePixelRatio;
const h = this.spectrumCanvas.height / window.devicePixelRatio;
const bufferLength = this.am.analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
this.am.analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, w, h);
// Dynamic grid color per visualizer mode
let gridCol = 'rgba(255, 153, 0, 0.12)';
if (this.mode === 'time-rotor' || this.mode === 'singularity-core') gridCol = 'rgba(0, 229, 255, 0.12)';
else if (this.mode === 'bio-heart') gridCol = 'rgba(16, 185, 129, 0.12)';
else if (this.mode === 'retro-oscilloscope') gridCol = 'rgba(34, 197, 94, 0.15)';
else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') gridCol = 'rgba(245, 158, 11, 0.15)';
ctx.strokeStyle = gridCol;
ctx.lineWidth = 1;
for (let y = 20; y < h; y += 30) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Draw segmented frequency bars
const numBars = 32;
const barWidth = (w / numBars) - 3;
for (let i = 0; i < numBars; i++) {
const binIdx = Math.floor(Math.pow(i / numBars, 1.8) * (bufferLength * 0.6));
const val = dataArray[binIdx] || 0;
const barHeight = Math.max(4, (val / 255) * (h - 20));
const x = i * (barWidth + 3);
const y = h - barHeight;
let col;
if (this.mode === 'time-rotor') {
if (i < 8) col = '#00e5ff';
else if (i < 20) col = '#38bdf8';
else if (i < 28) col = '#d4af37';
else col = '#ffffff';
} else if (this.mode === 'bio-heart') {
if (i < 8) col = '#10b981';
else if (i < 20) col = '#34d399';
else if (i < 28) col = '#a855f7';
else col = '#c084fc';
} else if (this.mode === 'retro-oscilloscope') {
col = i < 28 ? '#22c55e' : '#86efac';
} else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') {
if (i < 8) col = '#d97706';
else if (i < 20) col = '#f59e0b';
else if (i < 28) col = '#fbbf24';
else col = '#fef08a';
} else if (this.mode === 'singularity-core') {
if (i < 8) col = '#4f46e5';
else if (i < 20) col = '#6366f1';
else if (i < 28) col = '#38bdf8';
else col = '#ffffff';
} else {
// Starflight / Classic LCARD
if (i < 8) col = '#ff6600';
else if (i < 20) col = '#ff9933';
else if (i < 28) col = '#cc99cc';
else col = '#99ccff';
}
ctx.fillStyle = col;
ctx.shadowColor = col;
ctx.shadowBlur = val > 120 ? 8 : 0;
ctx.fillRect(x, y, barWidth, barHeight);
ctx.fillStyle = '#ffffff';
ctx.fillRect(x, y - 2, barWidth, 2);
}
ctx.shadowBlur = 0;
}
renderWarpCore() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const chamberWidth = Math.min(70, w * 0.4);
// 1. Draw outer intermix chamber housing
ctx.fillStyle = '#111625';
ctx.fillRect(centerX - chamberWidth / 2 - 8, 0, chamberWidth + 16, h);
// Chamber glass gradient
const glassGrad = ctx.createLinearGradient(centerX - chamberWidth / 2, 0, centerX + chamberWidth / 2, 0);
glassGrad.addColorStop(0, 'rgba(0, 50, 100, 0.4)');
glassGrad.addColorStop(0.5, 'rgba(0, 180, 255, 0.15)');
glassGrad.addColorStop(1, 'rgba(0, 50, 100, 0.4)');
ctx.fillStyle = glassGrad;
ctx.fillRect(centerX - chamberWidth / 2, 0, chamberWidth, h);
// 2. Matter / Antimatter injectors (Top and Bottom)
ctx.fillStyle = '#ff9900';
ctx.fillRect(centerX - chamberWidth / 2 - 4, 0, chamberWidth + 8, 12);
ctx.fillRect(centerX - chamberWidth / 2 - 4, h - 12, chamberWidth + 8, 12);
// 3. Central Reaction Intermix Chamber (Center glowing disc)
const centerY = h / 2;
const glowRadius = 24 + this.pulseEnergy * 28;
const coreGlow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, glowRadius);
coreGlow.addColorStop(0, '#ffffff');
coreGlow.addColorStop(0.3, `rgba(0, 210, 255, ${0.7 + this.pulseEnergy * 0.3})`);
coreGlow.addColorStop(0.7, `rgba(0, 100, 255, ${0.4 + this.pulseEnergy * 0.4})`);
coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(centerX, centerY, glowRadius, 0, Math.PI * 2);
ctx.fill();
// 4. Segmented Magnetic Constriction Coils (horizontal pulsing rings)
const numCoils = 14;
for (let i = 0; i < numCoils; i++) {
const coilY = (i / (numCoils - 1)) * (h - 30) + 15;
const distFromCenter = Math.abs(coilY - centerY) / (h / 2);
const coilIntensity = Math.max(0.2, (1.0 - distFromCenter * 0.6) * (0.4 + this.pulseEnergy * 0.6));
ctx.fillStyle = `rgba(0, 230, 255, ${coilIntensity})`;
ctx.shadowColor = '#00e6ff';
ctx.shadowBlur = this.pulseEnergy > 0.6 ? 12 : 3;
// Draw coil bar
ctx.fillRect(centerX - chamberWidth / 2 + 4, coilY - 2, chamberWidth - 8, 4);
}
ctx.shadowBlur = 0;
// 5. Plasma stream particles
for (let i = this.warpParticles.length - 1; i >= 0; i--) {
const p = this.warpParticles[i];
p.y += p.speed;
if (p.y < 0 || p.y > 1) {
if (this.warpParticles.length > 36) {
this.warpParticles.splice(i, 1);
continue;
} else {
p.y = p.speed > 0 ? 0 : 1;
}
}
const py = p.y * h;
const px = centerX + (Math.sin(p.y * 12) * (chamberWidth * 0.25));
ctx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + this.pulseEnergy * 0.6)})`;
ctx.beginPath();
ctx.arc(px, py, p.size * (0.8 + this.pulseEnergy * 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
/**
* Renders the canonical TARDIX Central Time Rotor
* A glass cylinder containing an interior mechanical column physically rising and falling
* in sync with the pulse cycle, illuminated with glowing Gallifreyan cyan/emerald light.
*/
renderTimeRotor() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const columnWidth = Math.min(84, w * 0.45);
// 1. TARDIX Console Plinth & Ceiling Collar (Victorian Brass / Gallifreyan Bronze)
const collarGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
collarGrad.addColorStop(0, '#593e10');
collarGrad.addColorStop(0.3, '#d4af37');
collarGrad.addColorStop(0.7, '#fef08a');
collarGrad.addColorStop(1, '#593e10');
ctx.fillStyle = collarGrad;
ctx.fillRect(centerX - columnWidth / 2 - 8, 0, columnWidth + 16, 14);
ctx.fillRect(centerX - columnWidth / 2 - 8, h - 14, columnWidth + 16, 14);
// 2. Outer Glass Column Tube
const glassGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
glassGrad.addColorStop(0, 'rgba(0, 40, 80, 0.45)');
glassGrad.addColorStop(0.15, 'rgba(0, 229, 255, 0.25)');
glassGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.12)');
glassGrad.addColorStop(0.85, 'rgba(0, 229, 255, 0.25)');
glassGrad.addColorStop(1, 'rgba(0, 40, 80, 0.45)');
ctx.fillStyle = glassGrad;
ctx.fillRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
// Glass edge highlights
ctx.strokeStyle = 'rgba(0, 229, 255, 0.6)';
ctx.lineWidth = 1.5;
ctx.strokeRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
// 3. Central Bobbing Time Rotor Column
// Physical oscillation: rises and falls smoothly
this.rotorPhase += 0.038;
const maxTravel = (h - 90) * 0.35;
const rotorOffset = Math.sin(this.rotorPhase) * maxTravel;
const rotorCenterY = (h / 2) + rotorOffset;
const rotorHeight = (h - 28) * 0.48;
// Moving Inner Rotor Rod & Glass Tubes
const innerWidth = columnWidth * 0.58;
// Glowing core glow
const coreGlow = ctx.createRadialGradient(centerX, rotorCenterY, 4, centerX, rotorCenterY, 36 + this.pulseEnergy * 30);
coreGlow.addColorStop(0, '#ffffff');
coreGlow.addColorStop(0.4, `rgba(0, 229, 255, ${0.7 + this.pulseEnergy * 0.3})`);
coreGlow.addColorStop(0.8, `rgba(0, 100, 200, ${0.3 + this.pulseEnergy * 0.4})`);
coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(centerX, rotorCenterY, 36 + this.pulseEnergy * 30, 0, Math.PI * 2);
ctx.fill();
// Inner mechanical tubes
ctx.fillStyle = '#00e5ff';
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 10 + this.pulseEnergy * 10;
ctx.fillRect(centerX - 4, rotorCenterY - rotorHeight / 2, 8, rotorHeight);
// Left and right secondary crystal tubes
ctx.fillStyle = 'rgba(180, 240, 255, 0.85)';
ctx.fillRect(centerX - innerWidth / 2 + 2, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
ctx.fillRect(centerX + innerWidth / 2 - 7, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
// Gallifreyan Circular Rotor Rings
for (let r = 0; r < 4; r++) {
const ringY = rotorCenterY - rotorHeight / 2 + (r * (rotorHeight / 3));
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(centerX, ringY, innerWidth / 2 + 2, 5, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.shadowBlur = 0;
// 4. Sparkling Vortex Time Energy Particles
for (let i = this.warpParticles.length - 1; i >= 0; i--) {
const p = this.warpParticles[i];
p.y += p.speed * 0.8;
if (p.y < 0.05 || p.y > 0.95) {
if (this.warpParticles.length > 36) {
this.warpParticles.splice(i, 1);
continue;
} else {
p.y = p.speed > 0 ? 0.05 : 0.95;
}
}
const py = p.y * h;
const px = centerX + (Math.sin(p.y * 16 + this.rotorPhase) * (columnWidth * 0.32));
ctx.fillStyle = `rgba(0, 229, 255, ${p.opacity * (0.5 + this.pulseEnergy * 0.5)})`;
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(px, py, p.size * (0.7 + this.pulseEnergy * 0.5), 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
}
/**
* Industrial Fusion Reactor (Nostromo, Serenity, Rocinante)
* Heavy containment walls, incandescent glowing amber plasma core, heat radiating coils
*/
renderIndustrialReactor() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const chamberW = Math.min(80, w * 0.42);
// Cast iron frame
ctx.fillStyle = '#1c150c';
ctx.fillRect(centerX - chamberW / 2 - 10, 0, chamberW + 20, h);
// Hazard warning bands at top and bottom
for (let x = centerX - chamberW / 2 - 10; x < centerX + chamberW / 2 + 10; x += 12) {
ctx.fillStyle = (x % 24 === 0) ? '#d97706' : '#1a1106';
ctx.fillRect(x, 0, 12, 10);
ctx.fillRect(x, h - 10, 12, 10);
}
// Incandescent molten amber core
const radius = 22 + this.pulseEnergy * 32;
const glow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, radius);
glow.addColorStop(0, '#ffffff');
glow.addColorStop(0.2, '#fef08a');
glow.addColorStop(0.5, `rgba(245, 158, 11, ${0.7 + this.pulseEnergy * 0.3})`);
glow.addColorStop(1, 'rgba(180, 83, 9, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
// Heat induction coil clamps
for (let i = 0; i < 9; i++) {
const cy = 20 + i * ((h - 40) / 8);
ctx.fillStyle = (i % 2 === 0) ? '#f59e0b' : '#78350f';
ctx.shadowColor = '#f59e0b';
ctx.shadowBlur = this.pulseEnergy > 0.6 ? 10 : 2;
ctx.fillRect(centerX - chamberW / 2, cy - 3, chamberW, 6);
}
ctx.shadowBlur = 0;
}
/**
* Living Leviathon Bio-Heart (Moya, Lexx, Species 8675309)
* Pulsing vascular heart sac with bioluminescent emerald/violet energy and neural veins
*/
renderBioHeart() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
// Organic vascular expansion
const bioScale = 1.0 + Math.sin(this.rotorPhase * 1.2) * 0.12 + this.pulseEnergy * 0.18;
const baseR = 35 * bioScale;
// Outer bioluminescent aura
const aura = ctx.createRadialGradient(centerX, centerY, 4, centerX, centerY, baseR * 1.8);
aura.addColorStop(0, '#a7f3d0');
aura.addColorStop(0.3, `rgba(16, 185, 129, ${0.7 + this.pulseEnergy * 0.3})`);
aura.addColorStop(0.7, `rgba(139, 92, 246, ${0.3 + this.pulseEnergy * 0.3})`);
aura.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = aura;
ctx.beginPath();
ctx.arc(centerX, centerY, baseR * 1.8, 0, Math.PI * 2);
ctx.fill();
// Pulsing neural veins
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#10b981';
ctx.shadowBlur = 8;
for (let v = 0; v < 6; v++) {
const angle = (v / 6) * Math.PI * 2 + this.rotorPhase * 0.2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
const cpX = centerX + Math.cos(angle + 0.5) * (baseR * 0.8);
const cpY = centerY + Math.sin(angle + 0.5) * (baseR * 0.8);
const endX = centerX + Math.cos(angle) * (baseR * 1.5);
const endY = centerY + Math.sin(angle) * (baseR * 1.5);
ctx.quadraticCurveTo(cpX, cpY, endX, endY);
ctx.stroke();
}
ctx.shadowBlur = 0;
}
/**
* Retro Oscilloscope & Analog Astrogator (Jupiter 2, Discovery One)
* 1950s/60s green phosphor CRT screen with glowing Lissajous audio wave rings
*/
renderRetroOscilloscope() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const crtRadius = Math.min(w, h) * 0.42;
// Circular CRT bezel
ctx.fillStyle = '#052e16';
ctx.beginPath();
ctx.arc(centerX, centerY, crtRadius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 2;
ctx.stroke();
// Crosshairs
ctx.strokeStyle = 'rgba(34, 197, 94, 0.25)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(centerX - crtRadius, centerY);
ctx.lineTo(centerX + crtRadius, centerY);
ctx.moveTo(centerX, centerY - crtRadius);
ctx.lineTo(centerX, centerY + crtRadius);
ctx.stroke();
// Draw a neutral trace before audio initialization, replacing the previous theme.
const analyser = this.am.analyser;
const bufferLength = analyser ? analyser.fftSize : 128;
const dataArray = new Uint8Array(bufferLength);
if (analyser) analyser.getByteTimeDomainData(dataArray);
else dataArray.fill(128);
ctx.strokeStyle = '#86efac';
ctx.shadowColor = '#22c55e';
ctx.shadowBlur = 8;
ctx.lineWidth = 2;
ctx.beginPath();
const points = 48;
for (let i = 0; i < points; i++) {
const idx = Math.floor((i / points) * (bufferLength / 2));
const v = (dataArray[idx] / 128.0) - 1.0;
const angle = (i / points) * Math.PI * 2 + this.coreTime;
const r = (crtRadius * 0.65) + (v * 28 * (0.8 + this.pulseEnergy));
const x = centerX + Math.cos(angle) * r;
const y = centerY + Math.sin(angle) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
// A sweep marker makes rotation legible even when the audio trace is silent.
const sweepRadius = (crtRadius * 0.65) + ((dataArray[0] / 128.0) - 1.0) * 28 * (0.8 + this.pulseEnergy);
ctx.fillStyle = '#d1fae5';
ctx.beginPath();
ctx.arc(centerX + Math.cos(this.coreTime) * sweepRadius,
centerY + Math.sin(this.coreTime) * sweepRadius, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
/**
* Gravity Singularity Core (Event Horizon, Deep Space)
* Black hole event horizon with warping gravitational accretion disk
*/
renderSingularityCore() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const diskR = Math.min(w, h) * 0.44;
// Glowing gravitational accretion disk
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.rotorPhase * 0.6);
const grad = ctx.createRadialGradient(0, 0, 12, 0, 0, diskR);
grad.addColorStop(0, '#000000');
grad.addColorStop(0.35, '#000000');
grad.addColorStop(0.45, `rgba(99, 102, 241, ${0.8 + this.pulseEnergy * 0.2})`);
grad.addColorStop(0.7, `rgba(56, 189, 248, ${0.4 + this.pulseEnergy * 0.3})`);
grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(0, 0, diskR, diskR * 0.35, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Pure black event horizon sphere at center
ctx.fillStyle = '#000000';
ctx.strokeStyle = 'rgba(99, 102, 241, 0.8)';
ctx.lineWidth = 2;
ctx.shadowColor = '#6366f1';
ctx.shadowBlur = 12 + this.pulseEnergy * 10;
ctx.beginPath();
ctx.arc(centerX, centerY, 18, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
}
}
window.StarshipVisualizer = StarshipVisualizer;
// Helper: Hex color to RGBA
function hexToRgba(hex, alpha = 1) {
if (!hex || hex.charAt(0) !== '#') return `rgba(56, 189, 248, ${alpha})`;
let c = hex.substring(1);
if (c.length === 3) c = c.split('').map(x => x + x).join('');
const num = parseInt(c, 16);
return `rgba(${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}, ${alpha})`;
}
/**
* ============================================================================
* CINEMATIC OBSERVATION LOUNGE ENGINE (v9g)
* ============================================================================
* Features:
* - 60fps DPI-Aware Deep Celestial Canvas (Parallax 3D Starfield & Warp Tunnel)
* - Relativistic Warp Flight vs. Orbital Cruise Impulse Modes
* - Procedural Celestial Bodies (Class-M Planet with Atmospheric Glow, Time Vortex, Gas Giants)
* - Living Traffic & Encounters (Shuttles, Cruisers, Decloaking Klingon BOP, TARDIX, Freighters)
* - Viewport Window Framing Architecture per Universe (Starflight, Industrial, Station, Whataverse, Military)
* - Emergency Alert Synchronization (Red/Yellow Alert Klaxon Strobes & Shield Grids)
* - Subspace Audio Harmonics Waveform Sill
* - Auto-Hiding Interactive Glass Control Dock
*/
// =========================================================================
// OBSERVATION CANVAS MANIFEST (v3co)
// =========================================================================
// Each universe declares exactly which canvas layers it draws. Default-deny:
// anything not listed is OFF. A universe missing from this table gets no canvas at all.
// This table is the contract that keeps each theme's OBSERVATION its own experience --
// do not add a layer here to "fill space"; give the theme its own bespoke content instead.
//
// `starfield` entries may carry a per-universe profile so that two universes drawing stars
// are still drawing THEIR OWN stars (density, palette, scale), not one shared layer.
@@ -0,0 +1,710 @@
/*
* XZBT Exhibit Contract 5.2 — generic contract core.
*
* WHY THIS FILE IS GENERIC
* ------------------------
* Everything in here is domain-free. It knows about the *shape* of the
* contract (envelopes, target kinds, revisions, sequences, error codes,
* capability lifecycle) and nothing about aquariums, planetariums, haunted
* houses, or any other subject matter. It contains no exhibit state, no
* exhibit vocabulary, and no transport.
*
* An exhibit supplies three things and gets a conforming contract surface:
*
* 1. a target catalog (canonical dotted IDs -> descriptors)
* 2. a setter table (target ID -> absolute, idempotent setter)
* 3. an action table (target ID -> real impulse implementation)
*
* The core owns the canonical mutation/invoke chokepoint, so every input
* source (native UI, hotkey, host transport) converges on one path and one
* set of transaction semantics. That is the whole point of sharing it: the
* contract rules are subtle enough that three hand-rolled copies would drift.
*
* This file is a classic script (no ES modules) and publishes one global.
*/
(function () {
'use strict';
var CONTRACT_MAJOR = 5;
var CONTRACT_MINOR = 2;
var XZBT_VERSION = '5.2';
/* 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'];
/* Contract 5.2 §24. */
var ERROR_CODES = [
'UNSUPPORTED_VERSION',
'INVALID_MESSAGE',
'INVALID_SESSION',
'UNKNOWN_TARGET',
'INVALID_VALUE',
'INVALID_ARGUMENTS',
'CAPABILITY_UNAVAILABLE',
'TARGET_READ_ONLY',
'TARGET_NOT_INVOKABLE',
'TARGET_NOT_SETTABLE',
'INTERNAL_ERROR'
];
/* Contract 5.2 §17. */
var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
/* Contract 5.2 §16. The base event set is closed; exhibits do not invent
* new canonical event types. */
var EVENT_TYPES = [
'state.changed',
'action.executed',
'selection.changed',
'capability.changed',
'registry.changed',
'error'
];
var TARGET_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
function isPlainObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isFiniteNumber(v) {
return typeof v === 'number' && isFinite(v);
}
/* ------------------------------------------------------------------ *
* Target catalog
* ------------------------------------------------------------------ */
/**
* A catalog is the exhibit's fixed public surface. It is built once and
* never reshaped by ordinary context changes (Authoring Guide §D/N):
* a target that is temporarily unusable stays discoverable and reports
* CAPABILITY_UNAVAILABLE instead of disappearing.
*/
function Catalog(descriptors) {
this._byId = {};
this._order = [];
for (var i = 0; i < descriptors.length; i++) {
var d = descriptors[i];
if (!TARGET_ID_PATTERN.test(d.id)) {
throw new Error('Invalid canonical target id: ' + d.id);
}
if (this._byId[d.id]) {
throw new Error('Duplicate target id: ' + d.id);
}
this._byId[d.id] = d;
this._order.push(d.id);
}
}
Catalog.prototype.has = function (id) {
return Object.prototype.hasOwnProperty.call(this._byId, id);
};
Catalog.prototype.get = function (id) {
return this.has(id) ? this._byId[id] : null;
};
Catalog.prototype.ids = function () {
return this._order.slice();
};
/** Descriptors as published by `describe`, in stable catalog order. */
Catalog.prototype.descriptors = function () {
var out = [];
for (var i = 0; i < this._order.length; i++) {
out.push(this._byId[this._order[i]]);
}
return out;
};
/* ------------------------------------------------------------------ *
* Capabilities
* ------------------------------------------------------------------ */
/**
* Capabilities are discoverable and stateful (Contract §17). This registry
* only records what the exhibit honestly reports; it does not invent
* lifecycle transitions. An exhibit that is always ready says so once.
*/
function CapabilityRegistry() {
this._caps = {};
this._order = [];
}
CapabilityRegistry.prototype.declare = function (id, state) {
if (CAPABILITY_STATES.indexOf(state) === -1) {
throw new Error('Unknown capability state: ' + state);
}
if (!this._caps[id]) this._order.push(id);
this._caps[id] = { id: id, state: state };
return this;
};
CapabilityRegistry.prototype.stateOf = function (id) {
return this._caps[id] ? this._caps[id].state : 'unsupported';
};
CapabilityRegistry.prototype.snapshot = function () {
var out = [];
for (var i = 0; i < this._order.length; i++) {
var c = this._caps[this._order[i]];
out.push({ id: c.id, state: c.state });
}
return out;
};
/** True when every required capability is usable right now. */
CapabilityRegistry.prototype.allUsable = function (requires) {
if (!requires || !requires.length) return true;
for (var i = 0; i < requires.length; i++) {
var s = this.stateOf(requires[i]);
if (s !== 'ready' && s !== 'busy') return false;
}
return true;
};
/* ------------------------------------------------------------------ *
* The contract core
* ------------------------------------------------------------------ */
/**
* @param {object} options
* @param {object} options.identity { product, version, build }
* @param {Catalog} options.catalog
* @param {CapabilityRegistry} options.capabilities
* @param {object} options.setters target id -> function(value) -> {changed:boolean}
* @param {object} options.readers target id -> function() -> value
* @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
*/
function ContractCore(options) {
this.identity = options.identity;
this.catalog = options.catalog;
this.capabilities = options.capabilities;
this.setters = options.setters || {};
this.readers = options.readers || {};
this.actions = options.actions || {};
this.availability = options.availability || {};
this.onEvent = options.onEvent || function () {};
/* stateRevision tracks committed persistent-state history and does NOT
* reset when a host reconnects (Contract §14, §16.1). */
this.stateRevision = 0;
/* registryRevision tracks the target set and its metadata. The catalog
* is fixed, so this starts at 1 and stays there unless the exhibit
* genuinely changes its registry. */
this.registryRevision = 1;
/* sequence is session-scoped and resets on a new sessionId. */
this.sequence = 0;
this.sessionId = null;
this._sessionCounter = 0;
this._eventLog = [];
}
ContractCore.prototype._emit = function (type, payload) {
if (EVENT_TYPES.indexOf(type) === -1) {
throw new Error('Not a canonical contract event type: ' + type);
}
this.sequence += 1;
var event = {
xzbt: XZBT_VERSION,
type: type,
sessionId: this.sessionId,
sequence: this.sequence,
timestamp: Date.now()
};
for (var k in payload) {
if (Object.prototype.hasOwnProperty.call(payload, k)) event[k] = payload[k];
}
this._eventLog.push(event);
this.onEvent(event);
return event;
};
/** Events emitted so far in the current session (used by tests/harness). */
ContractCore.prototype.eventLog = function () {
return this._eventLog.slice();
};
ContractCore.prototype._error = function (code, message) {
return { ok: false, error: { code: code, message: message } };
};
/* ------------------------------------------------------------------ *
* Value validation
* ------------------------------------------------------------------ */
ContractCore.prototype._validateValue = function (descriptor, value) {
var kind = descriptor.kind;
if (kind === 'range') {
if (!isFiniteNumber(value)) {
return this._error('INVALID_VALUE', 'Range target requires a finite number.');
}
if (value < descriptor.min || value > descriptor.max) {
/* No silent clamping: an out-of-range value is rejected outright
* (Authoring Guide §T). */
return this._error(
'INVALID_VALUE',
'Value ' + value + ' is outside [' + descriptor.min + ', ' + descriptor.max + '].'
);
}
if (descriptor.step) {
var steps = (value - descriptor.min) / descriptor.step;
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
return this._error(
'INVALID_VALUE',
'Value ' + value + ' is not on the declared step of ' + descriptor.step + '.'
);
}
}
return { ok: true, value: value };
}
if (kind === 'state') {
if (descriptor.valueType === 'boolean') {
if (typeof value !== 'boolean') {
return this._error('INVALID_VALUE', 'State target requires a boolean.');
}
return { ok: true, value: value };
}
if (descriptor.valueType === 'string') {
if (typeof value !== 'string') {
return this._error('INVALID_VALUE', 'State target requires a string.');
}
if (descriptor.maxLength && value.length > descriptor.maxLength) {
return this._error(
'INVALID_VALUE',
'String exceeds declared maxLength of ' + descriptor.maxLength + '.'
);
}
return { ok: true, value: value };
}
if (descriptor.valueType === 'number') {
if (!isFiniteNumber(value)) {
return this._error('INVALID_VALUE', 'State target requires a finite number.');
}
return { ok: true, value: value };
}
return this._error('INVALID_VALUE', 'Unsupported valueType.');
}
if (kind === 'selection') {
if (typeof value !== 'string') {
return this._error('INVALID_VALUE', 'Selection target requires a string value.');
}
for (var i = 0; i < descriptor.options.length; i++) {
if (descriptor.options[i].value === value) return { ok: true, value: value };
}
return this._error('INVALID_VALUE', 'Value "' + value + '" is not a declared option.');
}
return this._error('INVALID_VALUE', 'Target kind does not accept values.');
};
/* ------------------------------------------------------------------ *
* Canonical mutation path (Authoring Guide §H)
* ------------------------------------------------------------------ */
/**
* The single chokepoint for every externally visible persistent-state
* change, whatever its origin. One call is one mutation transaction:
* commit, then increment stateRevision at most once, then emit the
* resulting events carrying that revision (Contract §14).
*
* @param {string} targetId
* @param {*} value
* @param {string} source assigned by the caller's trusted boundary
* @param {object} [context] { correlationId }
*/
ContractCore.prototype.applyMutation = function (targetId, value, source, context) {
context = context || {};
if (SOURCES.indexOf(source) === -1) {
return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
}
var descriptor = this.catalog.get(targetId);
if (!descriptor) {
return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
}
if (descriptor.kind === 'impulse') {
return this._error('TARGET_NOT_SETTABLE', 'Impulse targets are not settable.');
}
if (!descriptor.writable) {
return this._error('TARGET_READ_ONLY', 'The requested target is read-only.');
}
if (!this.capabilities.allUsable(descriptor.requires)) {
return this._error(
'CAPABILITY_UNAVAILABLE',
'A capability required by this target is not usable.'
);
}
var validated = this._validateValue(descriptor, value);
if (!validated.ok) return validated;
var setter = this.setters[targetId];
if (typeof setter !== 'function') {
return this._error('INTERNAL_ERROR', 'No setter is bound to this target.');
}
/* The setter is absolute and idempotent: it reports whether anything
* actually changed. A no-op must not touch stateRevision (Contract §14.3). */
var result = setter(validated.value) || {};
if (!result.changed) {
return { ok: true, revisionChanged: false, stateRevision: this.stateRevision };
}
this.stateRevision += 1;
var revision = this.stateRevision;
/* A coherent multi-value transaction reports every value it changed so
* all of them are emitted under the one shared revision. */
var changed = result.changedTargets && result.changedTargets.length
? result.changedTargets
: [targetId];
for (var i = 0; i < changed.length; i++) {
var id = changed[i];
var d = this.catalog.get(id);
if (!d || !d.readable) continue;
var payload = {
stateRevision: revision,
target: id,
value: this.readValue(id),
source: source,
/* Always present, null when the change did not originate from a
* request. Emitting the key unconditionally keeps the event shape
* identical no matter which input path caused the change, so a host
* can rely on one schema (Authoring Guide §H). */
correlationId: context.correlationId || null
};
this._emit(d.kind === 'selection' ? 'selection.changed' : 'state.changed', payload);
}
return { ok: true, revisionChanged: true, stateRevision: revision };
};
/**
* The single chokepoint for every externally visible action.
* Impulses never change persistent state, so they never touch
* stateRevision (Contract §10.4, §14).
*/
ContractCore.prototype.invokeAction = function (targetId, args, source, context) {
context = context || {};
if (SOURCES.indexOf(source) === -1) {
return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
}
var descriptor = this.catalog.get(targetId);
if (!descriptor) {
return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
}
if (descriptor.kind !== 'impulse') {
return this._error('TARGET_NOT_INVOKABLE', 'Only impulse targets are invokable.');
}
if (!this.capabilities.allUsable(descriptor.requires)) {
return this._error(
'CAPABILITY_UNAVAILABLE',
'A capability required by this target is not usable.'
);
}
/* Contextual availability: the target stays discoverable, but may be
* temporarily unusable because of exhibit state (Authoring Guide §N). */
var gate = this.availability[targetId];
if (typeof gate === 'function') {
var verdict = gate();
if (verdict && !verdict.ok) {
return this._error(verdict.code || 'CAPABILITY_UNAVAILABLE', verdict.message || 'Unavailable.');
}
}
var action = this.actions[targetId];
if (typeof action !== 'function') {
return this._error('INTERNAL_ERROR', 'No action is bound to this target.');
}
// Fixture-only correction for the owner's authoritative 5.2 clarification.
if (!isPlainObject(args)) return this._error('INVALID_VALUE', 'args must be an object.');
var declared = descriptor.arguments || [];
var names = declared.map(function (arg) { return arg.name; });
if (Object.keys(args).some(function (key) { return names.indexOf(key) === -1; })) {
return this._error('INVALID_VALUE', 'Undeclared argument.');
}
for (var a = 0; a < declared.length; a++) {
var spec = declared[a];
if (!Object.prototype.hasOwnProperty.call(args, spec.name)) {
if (spec.required) return this._error('INVALID_VALUE', 'Missing argument: ' + spec.name);
continue;
}
var value = args[spec.name];
var validType = spec.type === 'integer' ? Number.isSafeInteger(value)
: spec.type === 'number' ? isFiniteNumber(value) : typeof value === spec.type;
if (!validType || (spec.enum && spec.enum.indexOf(value) === -1)) {
return this._error('INVALID_VALUE', 'Invalid argument: ' + spec.name);
}
if (typeof value === 'number') {
var units = (value - (spec.min === undefined ? 0 : spec.min)) / spec.step;
if ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max)
|| (spec.step !== undefined && Math.abs(units - Math.round(units)) > 1e-7)) {
return this._error('INVALID_VALUE', 'Argument outside numeric constraints.');
}
}
if (typeof value === 'string' && ((spec.minLength !== undefined && value.length < spec.minLength)
|| (spec.maxLength !== undefined && value.length > spec.maxLength))) {
return this._error('INVALID_VALUE', 'Argument outside length constraints.');
}
}
var outcome = action(args);
if (!outcome || !outcome.ok) {
return this._error(
(outcome && outcome.code) || 'INTERNAL_ERROR',
(outcome && outcome.message) || 'The action could not be executed.'
);
}
var payload = {
target: targetId,
args: outcome.args || args || {},
source: source,
correlationId: context.correlationId || null
};
this._emit('action.executed', payload);
return { ok: true };
};
/* ------------------------------------------------------------------ *
* Reads
* ------------------------------------------------------------------ */
ContractCore.prototype.readValue = function (targetId) {
var reader = this.readers[targetId];
if (typeof reader === 'function') return reader();
return null;
};
/** Contract §13: only readable persistent targets belong in `values`. */
ContractCore.prototype.stateSnapshot = function () {
var values = {};
var ids = this.catalog.ids();
for (var i = 0; i < ids.length; i++) {
var d = this.catalog.get(ids[i]);
if (d.kind === 'impulse' || !d.readable) continue;
values[ids[i]] = this.readValue(ids[i]);
}
return { stateRevision: this.stateRevision, values: values };
};
ContractCore.prototype.describe = function () {
return {
exhibit: this.identity,
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
registryRevision: this.registryRevision,
stateRevision: this.stateRevision,
capabilities: this.capabilities.snapshot(),
targets: this.catalog.descriptors()
};
};
/* ------------------------------------------------------------------ *
* Capability transitions
* ------------------------------------------------------------------ */
ContractCore.prototype.setCapabilityState = function (id, state) {
var previous = this.capabilities.stateOf(id);
if (previous === state) return false;
this.capabilities.declare(id, state);
this._emit('capability.changed', { capability: id, state: state, previousState: previous });
return true;
};
/* ------------------------------------------------------------------ *
* Session handling
* ------------------------------------------------------------------ */
ContractCore.prototype._newSessionId = function () {
this._sessionCounter += 1;
var rand = Math.floor(Math.random() * 0xffff).toString(16);
return 'sess-' + this._sessionCounter.toString(16) + rand;
};
/**
* Handshake (Contract §5). A new session resets the event sequence but
* deliberately leaves stateRevision alone.
*/
ContractCore.prototype.handleHello = function (message) {
var majors = message.supportedContractMajors;
if (Array.isArray(majors) && majors.length && majors.indexOf(CONTRACT_MAJOR) === -1) {
return {
ok: false,
error: {
code: 'UNSUPPORTED_VERSION',
message: 'No compatible contract major. This exhibit implements major ' + CONTRACT_MAJOR + '.'
}
};
}
this.sessionId = this._newSessionId();
this.sequence = 0;
this._eventLog = [];
return {
ok: true,
result: {
sessionId: this.sessionId,
exhibit: this.identity,
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
}
};
};
/* ------------------------------------------------------------------ *
* Request dispatch
* ------------------------------------------------------------------ */
/**
* Handle one already-parsed contract request. Transport-agnostic: the
* caller decides how the message arrived and what `source` that implies.
*
* @param {object} message
* @param {string} source the authoritative source for this arrival path
* @returns {object|null} response envelope, or null when the message is
* not addressed to this exhibit at all
*/
ContractCore.prototype.handleRequest = function (message, source) {
if (!isPlainObject(message)) {
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
}
if (message.xzbt !== XZBT_VERSION) {
return this._errorEnvelope(
message.requestId || null,
message.sessionId || null,
'UNSUPPORTED_VERSION',
'This exhibit implements contract ' + XZBT_VERSION + '.'
);
}
if (typeof message.type !== 'string') {
return this._errorEnvelope(
message.requestId || null,
message.sessionId || null,
'INVALID_MESSAGE',
'Message type is required.'
);
}
var requestId = typeof message.requestId === 'string' ? message.requestId : null;
if (message.type === 'hello') {
var hello = this.handleHello(message);
if (!hello.ok) {
return this._errorEnvelope(requestId, null, hello.error.code, hello.error.message);
}
return this._okEnvelope('hello.result', requestId, hello.result);
}
/* Every other request must carry the session issued by this exhibit. */
if (typeof message.sessionId !== 'string' || message.sessionId !== this.sessionId) {
return this._errorEnvelope(
requestId,
typeof message.sessionId === 'string' ? message.sessionId : null,
'INVALID_SESSION',
'A valid sessionId issued by this exhibit is required.'
);
}
switch (message.type) {
case 'describe':
return this._okEnvelope('describe.result', requestId, this.describe());
case 'state.get':
return this._okEnvelope('state.result', requestId, this.stateSnapshot());
case 'set': {
if (typeof message.target !== 'string') {
return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'set requires a target.');
}
var setResult = this.applyMutation(message.target, message.value, source, {
correlationId: requestId
});
if (!setResult.ok) {
return this._errorEnvelope(
requestId, this.sessionId, setResult.error.code, setResult.error.message
);
}
return this._okEnvelope('set.result', requestId, {
stateRevision: setResult.stateRevision,
changed: !!setResult.revisionChanged
});
}
case 'invoke': {
if (typeof message.target !== 'string') {
return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'invoke requires a target.');
}
var invokeResult = this.invokeAction(message.target, message.args, source, {
correlationId: requestId
});
if (!invokeResult.ok) {
return this._errorEnvelope(
requestId, this.sessionId, invokeResult.error.code, invokeResult.error.message
);
}
return this._okEnvelope('invoke.result', requestId, {});
}
default:
return this._errorEnvelope(
requestId, this.sessionId, 'INVALID_MESSAGE', 'Unsupported message type: ' + message.type
);
}
};
ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
var envelope = {
xzbt: XZBT_VERSION,
type: type,
requestId: requestId,
sessionId: this.sessionId,
ok: true
};
for (var k in payload) {
if (Object.prototype.hasOwnProperty.call(payload, k)) envelope[k] = payload[k];
}
return envelope;
};
ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
return {
xzbt: XZBT_VERSION,
type: 'error',
requestId: requestId,
sessionId: sessionId,
ok: false,
error: { code: code, message: message }
};
};
/* ------------------------------------------------------------------ *
* Exports
* ------------------------------------------------------------------ */
window.XZBTContractCore = {
VERSION: XZBT_VERSION,
CONTRACT_MAJOR: CONTRACT_MAJOR,
CONTRACT_MINOR: CONTRACT_MINOR,
SOURCES: SOURCES,
ERROR_CODES: ERROR_CODES,
CAPABILITY_STATES: CAPABILITY_STATES,
EVENT_TYPES: EVENT_TYPES,
TARGET_ID_PATTERN: TARGET_ID_PATTERN,
Catalog: Catalog,
CapabilityRegistry: CapabilityRegistry,
ContractCore: ContractCore
};
})();
@@ -0,0 +1,234 @@
/*
* Shared presentation shell for the reference exhibits.
*
* This is deliberately generic chrome only: page frame, panel layout,
* control primitives, and the announcement strip. Every domain-specific
* colour, texture, and scene style lives in the exhibit's own style.css.
* Nothing here knows what an exhibit is about.
*/
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family: "Segoe UI", Tahoma, Verdana, sans-serif;
font-size: 14px;
line-height: 1.45;
background: #10131a;
color: #e8ecf2;
overflow: hidden;
}
.exhibit {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
"header header"
"stage panel"
"footer footer";
height: 100vh;
gap: 10px;
padding: 10px;
}
.exhibit-header {
grid-area: header;
display: flex;
align-items: baseline;
gap: 12px;
padding: 8px 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.exhibit-header h1 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: 0.04em;
}
.exhibit-header .subtitle {
font-size: 12px;
opacity: 0.6;
}
.exhibit-header .status {
margin-left: auto;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
.stage {
grid-area: stage;
position: relative;
min-height: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.stage canvas {
display: block;
width: 100%;
height: 100%;
}
.panel {
grid-area: panel;
min-height: 0;
overflow-y: auto;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.panel h2 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.55;
}
.panel section + section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.control {
margin-bottom: 12px;
}
.control:last-child { margin-bottom: 0; }
.control label {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 12px;
margin-bottom: 5px;
opacity: 0.85;
}
.control .readout {
font-variant-numeric: tabular-nums;
font-size: 11px;
opacity: 0.7;
}
input[type="range"] {
width: 100%;
accent-color: currentColor;
}
select {
width: 100%;
padding: 5px 6px;
font: inherit;
font-size: 12px;
color: inherit;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 4px;
}
button {
font: inherit;
font-size: 12px;
padding: 6px 10px;
color: inherit;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 4px;
cursor: pointer;
}
button:hover { background: rgba(255, 255, 255, 0.14); }
button:active { transform: translateY(1px); }
button[aria-pressed="true"] {
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.4);
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.button-row button { flex: 1 1 auto; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
}
.toggle-row:last-child { margin-bottom: 0; }
.exhibit-footer {
grid-area: footer;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 12px;
min-height: 38px;
}
.exhibit-footer .announcement-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.45;
flex: 0 0 auto;
}
/* Announcement text is written with textContent only — never innerHTML. */
.exhibit-footer .announcement-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.9;
}
.exhibit-footer .announcement-text.is-idle { opacity: 0.4; font-style: italic; }
@media (max-width: 900px) {
.exhibit {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto auto;
grid-template-areas:
"header"
"stage"
"panel"
"footer";
height: auto;
min-height: 100vh;
}
.stage { min-height: 320px; }
body { overflow: auto; }
}
@@ -0,0 +1,94 @@
/*
* Shared presentation helpers for the reference exhibits.
*
* Generic DOM plumbing only: element lookup, safe text writing, and the
* announcement strip. It has no contract awareness and no domain knowledge,
* so an exhibit can use it, ignore it, or replace it without touching the
* contract layer.
*
* The one rule worth stating out loud: every piece of text that reaches the
* DOM goes through `textContent`. Nothing in this project ever assigns
* `innerHTML`, so a string arriving from a host or a scenario can never
* become markup or script (Contract §20, Authoring Guide §O).
*/
(function () {
'use strict';
function el(id) {
return document.getElementById(id);
}
/** Write text safely. Always textContent, never innerHTML. */
function setText(node, text) {
if (!node) return;
node.textContent = text === null || text === undefined ? '' : String(text);
}
/**
* Announcement strip. Text is data: it is truncated to the declared
* maximum and written with textContent.
*/
function Announcer(options) {
this.node = options.node;
this.idleText = options.idleText || '';
this.maxLength = options.maxLength || 120;
this._timer = null;
this.setText(this.idleText, true);
}
Announcer.prototype.setText = function (text, idle) {
if (!this.node) return;
var value = text === null || text === undefined ? '' : String(text);
if (value.length > this.maxLength) value = value.slice(0, this.maxLength);
this.node.textContent = value;
if (idle) {
this.node.classList.add('is-idle');
} else {
this.node.classList.remove('is-idle');
}
};
/** Show a transient message, then fall back to the idle text. */
Announcer.prototype.flash = function (text, ms) {
this.setText(text, false);
if (this._timer) clearTimeout(this._timer);
var self = this;
this._timer = setTimeout(function () {
self.setText(self.idleText, true);
}, ms || 2600);
};
/** Format a number for a readout, with a fixed number of decimals. */
function formatNumber(value, decimals) {
if (typeof value !== 'number' || !isFinite(value)) return '--';
return value.toFixed(decimals === undefined ? 2 : decimals);
}
/** Format a 0..1 value as a whole percentage. */
function formatPercent(value) {
if (typeof value !== 'number' || !isFinite(value)) return '--';
return Math.round(value * 100) + '%';
}
/** Format a 0..1 value as a whole number of degrees. */
function formatDegrees(value) {
if (typeof value !== 'number' || !isFinite(value)) return '--';
return Math.round(value * 360) + '\u00b0';
}
/** Format a 0..1 value as a whole number of minutes. */
function formatMinutes(value) {
if (typeof value !== 'number' || !isFinite(value)) return '--';
return Math.round(value * 60) + ' min';
}
window.XZBTShell = {
el: el,
setText: setText,
Announcer: Announcer,
formatNumber: formatNumber,
formatPercent: formatPercent,
formatDegrees: formatDegrees,
formatMinutes: formatMinutes
};
})();
@@ -0,0 +1,85 @@
/*
* XZBT Exhibit Contract 5.2 — same-origin postMessage host transport.
*
* This is layer 6 of the Authoring Guide's recommended separation, and it is
* deliberately the thinnest file in the project. It knows how to move
* contract messages across one channel and nothing else: no exhibit
* semantics, no target knowledge, no state.
*
* It is also entirely optional. An exhibit that never receives a `hello`
* behaves exactly as it would with this file deleted — that is the
* standalone-first rule (Contract §2, Authoring Guide §B).
*
* Security (Contract §25): both `event.origin` and `event.source` are
* validated on every message. The transport also assigns `source = 'host'`
* itself; a `source` field inside an incoming message is ignored, never
* trusted (Contract §15).
*/
(function () {
'use strict';
/**
* @param {object} options
* @param {object} options.core an XZBTContractCore.ContractCore
* @param {string} [options.origin] expected host origin; defaults to the
* document's own origin (same-origin)
* @param {function} [options.onMessage] diagnostics hook
*/
function HostTransport(options) {
this.core = options.core;
this.expectedOrigin = options.origin || window.location.origin;
this.onMessage = options.onMessage || function () {};
this.connected = false;
this._bound = this._onMessage.bind(this);
/* Events are pushed, not polled. The core already emits every state
* change, action, and capability transition through its onEvent hook;
* the transport's job is to forward them to the host. Without this the
* host would see responses but never learn that anything changed
* (Contract §16). */
var self = this;
var coreOnEvent = this.core.onEvent;
this.core.onEvent = function (event) {
if (typeof coreOnEvent === 'function') coreOnEvent(event);
if (self.connected) self.send(event);
};
window.addEventListener('message', this._bound);
}
HostTransport.prototype._isTrusted = function (event) {
/* Same-origin only. `file://` documents report origin "null", so a
* file-opened exhibit simply never accepts host traffic — which is the
* correct standalone behaviour, not a failure. */
if (event.origin !== this.expectedOrigin) return false;
if (event.source !== window.parent) return false;
return true;
};
HostTransport.prototype._onMessage = function (event) {
if (!this._isTrusted(event)) return;
var message = event.data;
if (!message || typeof message !== 'object') return;
if (message.xzbt !== window.XZBTContractCore.VERSION) return;
/* Source is assigned here, at the trusted receiving boundary. */
var response = this.core.handleRequest(message, 'host');
if (!response) return;
if (response.type === 'hello.result') this.connected = true;
this.onMessage(message, response);
this.send(response);
};
HostTransport.prototype.send = function (message) {
if (window.parent === window) return;
window.parent.postMessage(message, this.expectedOrigin);
};
HostTransport.prototype.destroy = function () {
window.removeEventListener('message', this._bound);
};
window.XZBTHostTransport = HostTransport;
})();
+188
View File
@@ -0,0 +1,188 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ExhibitHost } from '../src/host.js';
import { validateArgs, argumentSchema } from '../src/validation.js';
import { postMessageTransport } from '../src/transport/post-message.js';
import { createServer } from '../server/serve.js';
const descriptors = [
{ id: 'sample.enabled', kind: 'state', valueType: 'boolean' },
{ id: 'sample.level', kind: 'range', min: 0, max: 10, step: .5 },
{ id: 'sample.mode', kind: 'selection', options: [{ value: 'a' }, { value: 'b' }] },
{ id: 'sample.fire', kind: 'impulse', arguments: [{ name: 'message', type: 'string', required: true, maxLength: 8 }] }
].map(d => ({ readable: d.kind !== 'impulse', writable: d.kind !== 'impulse', requires: [], ...d }));
function peer() {
let receive, session = 0, sequence = 0, revision = 0;
const values = { 'sample.enabled': false, 'sample.level': 1, 'sample.mode': 'a' };
const requests = [];
const fixture = {
fail: null, drop: false, held: null, holdState: false, targets: structuredClone(descriptors),
caps: [{ id: 'service', state: 'ready' }],
transport: { subscribe(fn) { receive = fn; return () => {}; }, close() {}, send(m) {
requests.push(m);
if (fixture.drop) return;
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
if (fixture.fail) { receive({ ...out, type: 'error', error: { code: fixture.fail, message: 'Fixture rejection' } }); return; }
if (m.type === 'hello') { session++; sequence = 0; receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic test peer' } }); }
else if (m.type === 'describe') receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic test peer' }, contract: { major: 5, minor: 2 }, targets: structuredClone(fixture.targets), capabilities: structuredClone(fixture.caps), registryRevision: 1, stateRevision: revision });
else if (m.type === 'state.get') {
const snapshot = { ...out, type: 'state.result', stateRevision: revision, values: { ...values } };
if (fixture.holdState) fixture.held = () => receive(snapshot); else receive(snapshot);
} else {
if (m.type === 'set' && values[m.target] !== m.value) { values[m.target] = m.value; revision++; fixture.event(m.target === 'sample.mode' ? 'selection.changed' : 'state.changed', { target: m.target, value: m.value, stateRevision: revision }); }
if (m.type === 'invoke') fixture.event('action.executed', { target: m.target, args: m.args });
receive({ ...out, type: `${m.type}.result`, ok: true });
}
} },
event(type, payload = {}) { receive({ xzbt: '5.2', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), source: 'ui', ...payload }); },
requests, values, receive(m) { receive(m); }
};
return fixture;
}
async function connected(t) {
const fixture = peer(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 100 });
t.after(() => host.disconnect()); await host.connect(fixture.transport); return { host, fixture };
}
const settle = () => new Promise(resolve => setTimeout(resolve, 25));
test('negotiates, discovers and reads real session fields', async t => {
const { host, fixture } = await connected(t);
assert.equal(host.sessionId, 's1'); assert.equal(host.catalog.length, 4); assert.equal(host.values.get('sample.level'), 1);
assert.deepEqual(fixture.requests.map(r => r.type), ['hello', 'describe', 'state.get']);
assert.equal(fixture.requests[0].sessionId, undefined); assert.equal(fixture.requests[1].sessionId, 's1');
});
test('sets all persistent kinds, invokes arguments, preserves no-op revisions', async t => {
const { host } = await connected(t);
await host.set('sample.enabled', true); await host.set('sample.level', 2.5); await host.set('sample.mode', 'b');
assert.equal(host.stateRevision, 3); await host.set('sample.mode', 'b'); assert.equal(host.stateRevision, 3);
await host.invoke('sample.fire', { message: 'hello' });
assert.equal(host.sequence, 4); assert.equal(host.eventLog.at(-1).type, 'action.executed');
});
test('invalid values and required/unknown arguments never reach transport', async t => {
const { host, fixture } = await connected(t); const before = fixture.requests.length;
for (const [id, value] of [['sample.level', 11], ['sample.level', 1.1], ['sample.mode', 'c'], ['sample.enabled', 'true']]) {
await assert.rejects(host.set(id, value), { code: 'INVALID_VALUE' });
}
await assert.rejects(host.invoke('sample.fire', {}), { code: 'INVALID_VALUE' });
await assert.rejects(host.invoke('sample.fire', { message: 'hi', extra: true }), { code: 'INVALID_VALUE' });
assert.equal(fixture.requests.length, before); assert.equal(host.logs.at(-1).code, 'INVALID_VALUE');
});
test('argument type/enum/bounds/step/length rules and unsupported future types', () => {
const target = { arguments: [
{ name: 'text', type: 'string', required: true, minLength: 2, maxLength: 3 },
{ name: 'count', type: 'integer', required: true, min: 0, max: 4, step: 2 },
{ name: 'flag', type: 'boolean', required: false },
{ name: 'choice', type: 'number', required: false, enum: [1, 2] }
] };
validateArgs(target, { text: 'ok', count: 2, flag: false, choice: 1 });
for (const args of [{ text: 'x', count: 2 }, { text: 'long', count: 2 }, { text: 'ok', count: 3 }, { text: 'ok', count: 2, flag: 0 }, { text: 'ok', count: 2, choice: 3 }]) assert.throws(() => validateArgs(target, args), { code: 'INVALID_VALUE' });
validateArgs({}, {}); assert.throws(() => validateArgs({}, []), { code: 'INVALID_VALUE' });
assert.throws(() => argumentSchema({ arguments: [{ name: 'future', type: 'matrix', required: true }] }), { code: 'UNSUPPORTED_SCHEMA' });
});
test('surfaces exhibit errors and invalid session requires reconnect', async t => {
const { host, fixture } = await connected(t);
fixture.fail = 'CAPABILITY_UNAVAILABLE'; await assert.rejects(host.invoke('sample.fire', { message: 'go' }), { code: fixture.fail });
fixture.fail = 'INVALID_SESSION'; await assert.rejects(host.set('sample.level', 2), { code: fixture.fail });
assert.equal(host.status, 'invalid session'); fixture.fail = null;
await host.connect(fixture.transport); assert.equal(host.sessionId, 's2'); assert.equal(host.sequence, null);
});
test('unsupported version and timeout are explicit failures', async () => {
const fixture = peer(); const host = new ExhibitHost({ timeoutMs: 10 });
fixture.fail = 'UNSUPPORTED_VERSION'; await assert.rejects(host.connect(fixture.transport), { code: 'UNSUPPORTED_VERSION' });
fixture.fail = null; fixture.drop = true; await assert.rejects(host.connect(fixture.transport), { code: 'TIMEOUT' }); host.disconnect();
});
test('native events update cache; shared revision is not a gap', async t => {
const { host, fixture } = await connected(t);
fixture.event('state.changed', { target: 'sample.level', value: 3, stateRevision: 1 });
fixture.event('selection.changed', { target: 'sample.mode', value: 'b', stateRevision: 1 });
assert.equal(host.values.get('sample.level'), 3); assert.equal(host.values.get('sample.mode'), 'b');
assert.ok(!host.logs.some(l => l.code === 'REVISION_GAP'));
});
test('sequence and revision gaps resync and remain visible', async t => {
const { host, fixture } = await connected(t);
fixture.event('action.executed', { target: 'sample.fire' });
fixture.event('state.changed', { sequence: 4, stateRevision: 3, target: 'sample.level', value: 3 });
await settle(); assert.ok(host.logs.some(l => l.code === 'SEQUENCE_GAP')); assert.ok(host.logs.some(l => l.code === 'REVISION_GAP'));
assert.equal(fixture.requests.filter(r => r.type === 'state.get').length, 2);
});
test('capability and registry changes debounce discovery; normal state does not', async t => {
const { host, fixture } = await connected(t);
fixture.caps[0].state = 'error';
fixture.event('registry.changed'); fixture.event('registry.changed'); fixture.event('capability.changed');
await settle(); assert.equal(fixture.requests.filter(r => r.type === 'describe').length, 2);
assert.equal(host.capabilities[0].state, 'error');
fixture.event('state.changed', { target: 'sample.enabled', value: true, stateRevision: 1 });
await settle(); assert.equal(fixture.requests.filter(r => r.type === 'describe').length, 2);
});
test('events arriving during snapshot are replayed without lost updates', async t => {
const { host, fixture } = await connected(t); fixture.holdState = true;
const refresh = host.refresh();
fixture.event('state.changed', { target: 'sample.level', value: 7, stateRevision: 1 });
fixture.held(); await refresh; assert.equal(host.values.get('sample.level'), 7); assert.equal(host.stateRevision, 1);
});
test('old session traffic does not mutate new cache', async t => {
const { host, fixture } = await connected(t); await host.connect(fixture.transport);
fixture.receive({ xzbt: '5.2', type: 'state.changed', sessionId: 's1', sequence: 2, timestamp: 1, target: 'sample.level', value: 9, stateRevision: 2 });
assert.equal(host.values.get('sample.level'), 1); assert.equal(host.sequence, null);
});
test('malformed persistent events trigger recovery', async t => {
const { host, fixture } = await connected(t); fixture.event('state.changed', { target: 'sample.fire', value: true, stateRevision: 1 });
await settle(); assert.ok(host.logs.some(l => l.code === 'INVALID_MESSAGE')); assert.equal(host.values.has('sample.fire'), false);
});
test('postMessage verifies origin and source and cleans up', () => {
let callback, removed = false; const sent = [];
const peerWindow = { postMessage: (...args) => sent.push(args) };
const own = { location: { origin: 'http://localhost', href: 'http://localhost/' }, addEventListener(type, fn) { callback = fn; }, removeEventListener() { removed = true; } };
const transport = postMessageTransport({ src: 'http://localhost/exhibit', contentWindow: peerWindow }, own);
const received = []; transport.subscribe(m => received.push(m));
callback({ origin: 'http://evil', source: peerWindow, data: 1 }); callback({ origin: 'http://localhost', source: {}, data: 2 });
callback({ origin: 'http://localhost', source: peerWindow, data: 3 }); assert.deepEqual(received, [3]);
transport.send({ hello: true }); assert.equal(sent[0][1], 'http://localhost'); transport.close(); assert.ok(removed);
});
test('local server serves host and denies repository/private paths', async t => {
const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); t.after(() => server.close());
const base = `http://127.0.0.1:${server.address().port}`;
const home = await fetch(base); assert.equal(home.status, 200); assert.match(await home.text(), /XZBT-NGN Exhibit Engine/);
for (const route of ['/AGENTS.md', '/.git/config', '/src/..%5cAGENTS.md']) assert.equal((await fetch(base + route)).status, 404);
});
test('invalid reported types do not contaminate the cache', async t => {
const { host, fixture } = await connected(t);
fixture.event('state.changed', { target: 'sample.level', value: null, stateRevision: 1 });
assert.equal(host.values.get('sample.level'), 1);
assert.ok(host.logs.some(l => l.code === 'INVALID_MESSAGE'));
await settle(); assert.equal(host.sync, 'synchronized');
});
test('invalid snapshot values preserve previous cache and mark uncertainty', async t => {
const { host, fixture } = await connected(t);
fixture.values['sample.level'] = null;
await assert.rejects(host.refresh(), { code: 'INVALID_MESSAGE' });
assert.equal(host.values.get('sample.level'), 1); assert.equal(host.sync, 'uncertain');
});
test('snapshot revision cannot roll back a previously observed transaction', async t => {
const { host, fixture } = await connected(t);
fixture.event('state.changed', { target: 'sample.level', value: 4, stateRevision: 1 });
await assert.rejects(host.refresh(), /revision regressed/);
assert.equal(host.stateRevision, 1); assert.equal(host.values.get('sample.level'), 4);
});
test('registry rediscovery adds descriptors and removes obsolete cached targets', async t => {
const { host, fixture } = await connected(t);
fixture.targets = fixture.targets.filter(d => d.id !== 'sample.mode'); delete fixture.values['sample.mode'];
fixture.targets.push({ id: 'unrelated.reading', kind: 'state', valueType: 'number', readable: true, writable: false, requires: [] });
fixture.values['unrelated.reading'] = 6;
fixture.event('registry.changed'); await settle();
assert.equal(host.catalog.length, 4); assert.equal(host.values.has('sample.mode'), false);
assert.equal(host.values.get('unrelated.reading'), 6);
});
test('unsupported future impulse arguments do not hide the catalog', async t => {
const { host, fixture } = await connected(t);
fixture.targets.at(-1).arguments = [{ name: 'future', type: 'matrix', required: true }];
await host.refresh(true); assert.equal(host.catalog.length, 4);
await assert.rejects(host.invoke('sample.fire', { future: [] }), { code: 'UNSUPPORTED_SCHEMA' });
});
test('capability requirements block unusable services without deleting targets', async t => {
const { host, fixture } = await connected(t);
fixture.targets.at(-1).requires = ['service']; fixture.caps[0].state = 'error';
await host.refresh(true); assert.equal(host.catalog.length, 4);
await assert.rejects(host.invoke('sample.fire', { message: 'hello' }), { code: 'CAPABILITY_UNAVAILABLE' });
});