# Step 6.7A — SciFi Observation Surface Architecture **Status:** design only. No implementation performed. No source files modified. **Scope:** adapting SciFi-XZBT Observation to the already-proven Contract 5.3 local presentation-surface model (Steps 6.3–6.6). **Audience:** the implementation model executing Step 6.7B. ### Repositories inspected | Repo | Path | Role | | --- | --- | --- | | XZBT-NGN | `G:/.vibe/XZBT-NGN` | Host engine, contract spec, test fixtures | | SciFi-XZBT | `G:/.vibe/SciFi-XZBT` | **The real exhibit source.** Step 6.7B edits here. | `test-fixtures/reference-exhibits/scifi/` inside XZBT-NGN is a *copy* of SciFi-XZBT, kept for NGN's own tests (`test-fixtures/PROVENANCE.md`). Step 6.7B must change SciFi-XZBT and then resync the fixture. Both working trees carry uncommitted work; nothing was modified by this task. --- ## 1. Recommendation **Adopt Candidate A in its narrowest form: one document, one script set, two boot modes.** SciFi advertises exactly two surfaces. The primary is the existing `index.html` (unchanged, the full console). The Observation surface is *the same document* re-served with a query string — `?surface=observation&xi=` — which Contract 5.3 §31.4 explicitly provides for ("a bare query string and/or fragment, resolving against the exhibit's own base document, for a single-page exhibit whose surfaces are views within one already-served document"). A boot-mode resolver read at the top of `app.js` decides which mode the instance runs in. In Observation mode the instance constructs no audio graph, no generative engine, no contract host bridge, and no ambient event scheduler; it renders the existing `#observation-overlay` and mirrors state from the authoritative console instance over an exhibit-internal, instance-scoped `BroadcastChannel`. This is the only candidate that survives contact with the actual code. Roughly 3,230 of `app.js`'s 4,703 lines (lines ~1261–4494) are the Observation renderer, and they live inside `app.js`'s single IIFE closure, reading `activeUniverseId`, `activePresetId`, `isPlaying`, `observationActivity`, `UniverseRegistry`, `observationEngine`, `alerts` and `audioManager` as free variables. `observation-engine.js` additionally reads five *console-panel* element IDs (`toggle-observation-viewport`, `val-observation-viewport`, `toggle-observation-pillars`, `val-observation-pillars`, and the preset select) that do not exist outside the console document. Keeping one document means **zero renderer extraction and zero DOM decoupling**: every element the renderer reaches for is still present, merely hidden by a body class. A dedicated lightweight page (Candidate B) would require lifting that entire closure into a shared module — the single largest and riskiest refactor available in this project — and would still not fit the distribution, because `tools/package.ps1` inlines exactly one input document and emits exactly one output file. A second HTML document simply cannot exist in the standalone single-file build. The second load-bearing decision: **surface existence is not `view.observation`.** `view.observation` keeps its current meaning exactly — the console page's own full-screen overlay — and opening or closing the NGN Observation pane never touches it. Conflating them would black out the operator's console the moment they opened the presentation pane, which is precisely the simultaneity failure Candidate C is rejected for. The Observation surface renders the scene unconditionally while it is open; that is its whole purpose and it needs no state flag to say so. --- ## 2. Current SciFi state architecture ### 2.1 Where state actually lives `app.js` is one `DOMContentLoaded` IIFE. There is no state object. Authority is distributed across subsystem instances and three closure variables, and `getContractStateSnapshot()` (app.js:4666) is the one function that composes them into the contract-visible view. | Contract state | Authoritative owner | Read path | | --- | --- | --- | | `universe.selected` | `let activeUniverseId` (app.js:29) | closure var | | `preset.selected` | `let activePresetId` (app.js:30) | closure var | | `transport.playing` | `let isPlaying` (app.js:28) | closure var | | `transport.muted`, `mix.master` | `AudioManager` | `audioManager.isMuted` / `.getMasterVolume()` | | `mix.hull.*` | `HullDroneSynth.params` | `hullDrone.params.*` | | `mix.drive.*` | `WarpCoreSynth.params` | `warpCore.params.*` | | `mix.environment.*` | `LifeSupportSynth.params` | `lifeSupport.params.*` | | `mix.telemetry.*` | `TelemetrySynth.params` | `telemetry.params.*` | | `fnc.enabled`, `fnc.level` | `FuturisticNoiceCancellation` | `fncSystem.enabled` / `.level` | | `speech.robot-amount` | `XZBTGenerativeExperience` | `generativeExperience.roboticAmount()` | | `alert.active` | `AlertSynth.activeAlert` | `alerts.activeAlert` | | `view.observation` | `let observationActive` (app.js:1281) | closure var | | `view.activity` | `let observationActivity` (app.js:1282) | closure var | | `view.viewport-frame` | `ObservationEngine.showViewport` | `observationEngine.showViewport` | | **`showPillars`** *(no target)* | `ObservationEngine.showPillars` | — | | **warp flight** *(no target)* | `ObservationEngine` internal | — | | **HUD hold** *(no target)* | console checkbox DOM only | — | **Canonical vs reflected.** The subsystem/closure values above are canonical. All DOM is *reflected*: every setter in the adapter's `bindings.setters` writes the subsystem first and then assigns `slider.value` / `label.textContent`. There is exactly one genuine exception — **HUD hold** is stored nowhere but `#toggle-observation-hud.checked`, which is why it is treated as surface-local in §7 rather than promoted to a target. **Observation does not own its own mutation path.** `observationEngine.onMutation` (app.js:259) routes every dock interaction straight into `contractAdapter.applyMutation(...)` with source `'ui'` — except `togglePillars()` and `toggleWarpFlight()`, which mutate engine-local fields directly because no canonical target exists for them (see §11). **The adapter reads the same authority.** `bindings.getState` is literally `() => getContractStateSnapshot()`. There is no second state computation anywhere. ### 2.2 Flow diagram (actual) ```text NGN (postMessage) console UI hotkeys Observation dock | | | | | | | | observationEngine.onMutation v v v v adapter.handleMessage +------------+---------------+ | | v v XZBTControlBus (intercepted) --> contractAdapter.applyMutation / invokeAction | [ THE one chokepoint ] v bindings.setters[id] / invokers[id] | +-------------------+-----------+-----------+-------------------+ v v v v audio subsystems closure vars ObservationEngine reflected DOM (AudioManager, (activeUniverseId, (showViewport, canvas (sliders, labels, synths, FNC, activePresetId, scene, bezels) dock button text) alerts) isPlaying, observationActive, observationActivity) | | | +---------+---------+-----------------------+ v _commitChanges() -> getContractStateSnapshot() diff | +--> stateRevision++ , state.changed / selection.changed +--> _emitEvent(...) [ GATED on sessionActive ] | v NGN host session ``` **The gate marked above is critical for 6.7B.** `_emitEvent` begins `if (!this.sessionActive) return;` (contract-adapter.js:596). With no NGN attached, **no events are emitted at all.** A mirror built on contract events would therefore go dead in standalone multi-window use. §6.4 specifies the fix. ### 2.3 Observation entry/exit today - `enterObservation()` (app.js:4443) — calls `generativeExperience.prepareExperience()` **(the WebLLM/Kokoro loader)**, sets `observationActive = true`, adds `.active` to `#observation-overlay`, calls `refreshObservation()`, starts the HUD/return fade timers, and calls `scheduleObservationAmbientActivity()`. - `exitObservation()` (app.js:4460) — stops the engine, the ambient timer, live instrumentation, spline morphs and the scene loop, then clears the stage markup. - `refreshObservation()` (app.js:4422) — injects `getObservationSvg(activeUniverseId)`, starts the scene and instrumentation, and calls `observationEngine.start(activeUniverseId)`. - Every entry/exit path (button app.js:4502, background click 4508, return pill 4517, Escape 4574) goes through `applyMutation('view.observation', …)`. Nothing toggles `observationActive` directly. This is already clean. ### 2.4 Assets, packaging and existing surface concepts - `index.html` loads one stylesheet and ten scripts in a **fixed order**, all classic globals. `agents.md` forbids ES modules, dependencies and network access outside the one bounded `generative-experience.js` exception. - `tools/package.ps1` walks `index.html` line by line and inlines any `` or `` that matches its exact regex, emitting **one** file, `dist/SciFiAmbientDisplay_V.html`. One input document, one output document. There is no other build step. - **No existing surface concept.** `describe()` (contract-adapter.js:854) returns `exhibit / contract / registryRevision / stateRevision / capabilities / targets` and no `surfaces` field. SciFi is currently a conformant Contract 5.3 exhibit in §31.3 form 1 ("absent"). - The nearest adjacent concept is the **TV PRESENTATION** card (16:9 aspect lock, render density). It is console-local presentation tuning, not a surface, and Step 6.7 should not disturb it. - **No Step 6.7 planning notes exist** in either repo. The only forward references are `STEP5-MVP-COMPLETION-REPORT.md:234` (the step list) and `docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md:393`, which names §7.2/§8 as "the reusable template Phase 6.7 adapts" — that template is the attachment sequence this design follows. --- ## 3. Candidate comparison | | **A — same document, surface mode** | B — dedicated lightweight page | C — reuse control page/overlay | | --- | --- | --- | --- | | Architectural cleanliness | Good. One app, one code path, mode is a boot input. | Cleanest *in theory*; unreachable in practice. | Poor. Conflates host UI with exhibit presentation. | | Renderer refactoring | **None.** All ~3,230 renderer lines and all DOM stay put. | Extract the renderer from `app.js`'s closure; decouple 5 console-panel element reads in `observation-engine.js`. Very large. | None. | | Standalone behavior | Untouched. `index.html` with no query is byte-for-byte today's app. | Untouched, but the new page has no place in the single-file build. | Untouched. | | Duplicate initialization | Controlled: mode guard skips audio, AI, adapter bridge, visualizer, hotkeys, timers. | Structurally impossible to duplicate. | N/A. | | State authority | Single. Presentation instance never calls `applyMutation` locally. | Single. | Single, but only one view can exist. | | Sync complexity | One instance-scoped BroadcastChannel; Museum's proven shape. | Same. | None. | | Contract purity | Exactly the §31.4 query-string form the spec provides for. | Fine. | Fine, but defeats §31.1's "independently viewable". | | NGN genericity | No NGN change. | No NGN change. | Would tempt NGN into exhibit-specific pane handling. | | Future cast suitability | Good — the surface is already a standalone URL with its own viewport. | Best (smallest payload), marginally. | Bad. | | Packaging | `package.ps1` unchanged; two new inlined scripts. | **Breaks.** Second document cannot be packaged. | Unchanged. | | Implementation risk | Moderate, concentrated in `app.js` mode branching. | High. | Low, but wrong. | ### Rejections - **B — Dedicated lightweight Observation surface.** Rejected on two independent grounds, either sufficient. (i) *Packaging:* `tools/package.ps1` produces a single self-contained document; a second HTML page has nowhere to go in the offline deliverable, so SciFi would fork into two maintained applications — explicitly forbidden by the brief. (ii) *Cost and risk:* it requires lifting ~3,230 lines out of a shared closure that `agents.md` marks "surgical, scoped edits — do not rewrite", plus decoupling `observation-engine.js` from five console-panel element IDs. The payoff is a lighter payload; but under Candidate A the presentation instance already parses the same bytes the browser has cached and *runs* almost none of the heavy subsystems, so the real saving is parse time, not runtime. Not worth the refactor. Revisit only if remote casting later demands a minimal payload. - **C — Reuse the existing page / overlay.** Rejected: it makes simultaneous control and observation impossible, which is the entire point of the multi-surface model (§31.1 "independently viewable"); NGN would end up manipulating exhibit DOM, breaking the generic boundary Step 6.4 established; and it has no path to casting. - **D — Better architecture found in the repository.** None found. The repository does, however, supply two refinements adopted into A: the §31.4 query-string surface form, and Museum Gallery's attach/snapshot/mutate/detach bus — improved here with an instance-scoped channel name (§6.2). --- ## 4. Surface catalog Two surfaces. No more are justified: the console is one coherent operator view, and Observation is the one thing an audience is meant to look at. Adding per-universe or per-panel surfaces now would be speculative. ```js // js/surface-mode.js — instanceId is the live per-document id (see §6.2) function SURFACES(instanceId) { return [ { id: 'surface.console', label: 'Main Console', kind: 'surface', primary: true, url: 'index.html', role: 'control', category: 'console', description: 'The full SciFi-XZBT operator console.' }, { id: 'surface.observation', label: 'Observation', kind: 'surface', primary: false, url: '?surface=observation&xi=' + encodeURIComponent(instanceId), role: 'ambient', category: 'observation', aspectRatio: '16:9', requires: ['observation'], description: 'Full-screen procedural viewscreen mirroring the console.' } ]; } ``` Schema notes, all verified against §31.2 and `src/validation.js`: - `id` uses the canonical target-ID grammar (§8.1) and matches Museum's `surface.control` / `surface.info-wall` convention. - `kind: 'surface'` is the required constant. Exactly one `primary: true`. - Only documented optional fields are used: `description`, `role`, `aspectRatio`, `category`, `requires`. **No invented fields.** No field names a transport, endpoint or device class (§31.9). - `requires: ['observation']` reuses the adapter's already-declared `observation` capability (contract-adapter.js:52). ### Why `url: 'index.html'` for the primary `src/local-surfaces.js:35` marks a descriptor as the already-open control pane with **full URL equality** against `host.exhibitBaseUrl`, and `src/connection.js` sets that base from `frame.contentWindow.location.href` after load. Connecting NGN to `…/scifi/index.html` — the URL already used by `README.md:54` and the Step 6.6 procedure — makes `index.html` resolve to exactly the base, so the primary reuses the existing session frame and is never opened a second time. See risk **R1** for the trailing-slash case. ### Why the Observation `url` is a bare query §31.4 sanctions it for single-page exhibits, `resolveSurfaceURL` accepts it (relative, same-origin, no scheme), and `server/serve.js` routes on `url.pathname` only, so the `HEAD` resource check in `checkSurfaceResource` returns 200. No new file is served and `package.ps1` never sees a second document. --- ## 5. Authority and synchronization ### 5.1 Ownership rule > The instance whose resolved mode is `console` is the sole authority. It owns > every subsystem, the one contract session, and the one mutation chokepoint. An > instance whose mode is `observation` owns **nothing**: it holds a read-only > mirror and may only *request* mutations. The presentation instance never calls `contractAdapter.applyMutation`, `invokeAction`, or any `bindings.setter`. It never constructs a contract session: it does not install the window message bridge, so a stray `hello` cannot reach it, and NGN never offers one — `LocalSurfaces` attaches no transport to surface frames (`src/local-surfaces.js`, and `tests/local-surfaces.test.js:232` asserts a presentation window cannot impersonate an authoritative peer). ### 5.2 Mechanism: instance-scoped BroadcastChannel Chosen mechanism: `BroadcastChannel`, named `xzbt-scifi-surface-v1:`, with the console document as owner — Museum Gallery's proven shape (`surface-bus.js`), with one deliberate improvement. Evaluated and rejected: - **Explicit parent/child `postMessage`.** *Structurally impossible here.* Under NGN the console document and the Observation iframe are **siblings** inside the NGN page, not parent and child. Neither holds a window handle to the other. This alone decides the mechanism. - **`storage` events / same-origin storage.** No request/response, so late join needs a polled or mirrored snapshot key; fires only in *other* documents; serializes on every change. Strictly worse. - **`SharedWorker`.** Extra lifecycle and an unsupported-context risk, for no benefit over BroadcastChannel. - **Extending `XZBTControlBus`.** It is an in-document registry with no transport; extending it means writing one of the above anyway. Keep it as-is. **The improvement over Museum: the channel name carries an instance id.** Museum uses one fixed channel name, which means two independent Museum tabs on one origin would cross-talk. For SciFi that matters more: two console tabs is a normal thing to do. The console mints `instanceId` once per document load, publishes it inside the Observation surface URL (which `describe()` produces at call time, so it is always current), and the presentation instance reads it from its own query string. A surface with an unknown or stale id finds no owner and lands in the waiting state rather than attaching to the wrong console. ### 5.3 Message shapes (exhibit-internal; never seen by a host) ``` surface -> owner : { type:'attach', requestId, participantId } owner -> surface : { type:'attach.snapshot', inReplyTo, values, stateRevision, registryRevision, presentation:{ tickerText } } owner -> all : { type:'state', target, value, stateRevision } owner -> all : { type:'action', target, args } owner -> all : { type:'presentation', kind:'ticker'|'obs-activity', … } surface -> owner : { type:'mutate', kind:'set'|'invoke', target, value|args } surface -> owner : { type:'detach', participantId } ``` This carries no XZBT envelope, no `xzbt` field, no `sessionId`, and is never observed by NGN — §31.7's explicit allowance. Participant bookkeeping is a `Set` of `participantId`s, not a counter, so duplicate attach or late detach cannot ratchet the count (Museum's lesson; see its participant-lifecycle tests). ### 5.4 The event-gate correction (mandatory) Because `_emitEvent` early-returns unless `sessionActive`, the mirror **must not** be fed from contract events. Add one exhibit-internal hook to the adapter, independent of session state: ```js // contract-adapter.js, inside _commitChanges(), after the event loop: if (this.onLocalChange) { for (const target of changes) { this.onLocalChange(target.id, after[target.id], this.stateRevision); } } // and in _invokeAction(), after _commitChanges: if (this.onLocalAction) this.onLocalAction(canonicalId, sanitizedArgs); ``` Contract event semantics are untouched — a host still only sees events inside a session. The mirror gets a session-independent feed, so Observation works with or without NGN attached. ### 5.5 Guarantees - **One authority:** enforced structurally — the presentation branch never constructs the mutation path's callers. - **Late join:** `attach` → owner replies with the live `contractAdapter.getContractState()` plus transient presentation extras. - **Reload:** a reload is a fresh `attach` with a fresh `participantId`; identical path to first join. - **Prompt propagation:** `onLocalChange` fires inside `_commitChanges`, i.e. in the same turn as the mutation. - **Surface interactions:** routed as `mutate` and executed by the owner through `contractAdapter.applyMutation(..., 'ui')` — the same chokepoint NGN's `set` uses, satisfying §31.7's mutation/revision/source/event requirement. - **No NGN-specific logic:** the bus is unaware NGN exists. --- ## 6. Audio / AI / procedural subsystem ownership Audio is already safe by construction: `AudioManager`'s constructor (`audio.js`) sets `this.ctx = null` and creates nothing; the `AudioContext` and every node appear only in `init()`, reached only via `resume()`. So a presentation instance that never engages transport never creates an audio graph. The dangerous paths are the ones that *call* `resume()` or `prepareExperience()`. | Subsystem | Authoritative console | Observation surface | Note | | --- | --- | --- | --- | | `AudioManager` / `AudioContext` | **Yes** | Constructed, **never `init()`/`resume()`** | Needed only to satisfy `ObservationEngine`'s constructor arity. | | Hull / Warp / LifeSupport / Telemetry / Alert / Whataverse / Expanded / EngineTransition synths | **Yes** | Constructed, silent | Same reason; they create nodes only on play. | | `FuturisticNoiceCancellation` | **Yes** | Constructed, inert | Do not call `setEnabled`/`setLevel` locally. | | `XZBTGenerativeExperience` (WebLLM + Kokoro) | **Yes** | **Not constructed. Not bound. Never prepared.** | `enterObservation()` calls `prepareExperience()` — **this call must be skipped in presentation mode**, or every opened pane downloads a 1.7B model. Highest-value guard in this design. | | `XZBTContractAdapter` + window bridge | **Yes — the one session** | **Not constructed** | Guarantees no second contract session. | | `XZBTControlBus` | **Yes** | Not constructed | | | `StarshipVisualizer` (spectrum, warp core) | **Yes** | Not constructed | Its canvases are console-panel only. | | `CoreAnimations` | **Yes** | Not constructed | | | Sleep timer | **Yes** | Not constructed | | | Hotkey handler (`window keydown`) | **Yes** | **Not installed** | A keystroke on the pane must not mutate state; see §7. | | Ambient activity scheduler (`scheduleObservationAmbientActivity`) | **Yes — authority only** | **Disabled**; renders broadcast events | Prevents duplicate random event generation. | | `triggerObservationActivity` | Decides + broadcasts | Renders on receipt | | | AI announcement / ticker text | **Yes** (`onGenerated`) | Renders relayed text | Relayed as `presentation/ticker`; announcement text is not contract state. | | `ObservationEngine` (canvas, bezels, waveform) | Yes (when overlay active) | **Yes — renderer only** | `am.analyser` guards already exist (`observation-engine.js:2262, 3097`). | | Observation SVG scene, spline morphs, scene entity loop | Yes (when overlay active) | **Yes — renderer only** | Deterministic from universe + preset + activity. | | Live instrumentation digit churn (`observationInstrumentTick`) | Yes | **Yes — local, deliberately** | See below. | | `ObservationBezels` | Yes | Yes | Pure SVG generation. | | Web3D / external visual deps | None exist | None | SciFi is canvas + SVG only. | ### Two judgment calls, stated explicitly **1. Instrumentation digit churn stays local.** `observationInstrumentTick` mutates cosmetic numerals in the HUD text. It is high-frequency, carries no narrative or contract meaning, and mirroring it would need a continuous high-rate channel for nothing. Two screens showing different filler digits is invisible. This is a deliberate, bounded divergence — and it is the *only* one. **2. Transient activity events are mirrored, not regenerated.** By contrast, `triggerObservationActivity` spawns visible narrative beats (contacts, expanding rings, text cards). Regenerating them independently would give two screens different events and would violate the brief's "no duplicate random event generation". The authority decides; the surface renders. ### The audience-scheduler correction (mandatory) `triggerObservationActivity` begins `if (!observationActive …) return;` and `scheduleObservationAmbientActivity` refuses to schedule unless `observationActive`. So with the console in normal mode and only the NGN Observation pane open, **nothing would ever be generated** — the surface would render a static scene. 6.7B must decouple the scheduler from local overlay visibility: ```js const observationAudienceActive = () => observationActive || surfaceOwner.attachedCount() > 0; ``` Use that predicate for *scheduling and deciding* activity; keep `observationActive` for *local rendering* only. The activity decision is then broadcast, and the console renders it locally only when its own overlay is up. ### Audio-reactive fidelity (accepted limitation) With no audio graph, `am.analyser` is null on the surface, so the waveform sill and the audio-reactive modulation read zero. `ObservationEngine` already guards this and degrades to a flat trace; the scene itself is procedural and unaffected. Mirroring a scalar audio level is a reasonable future refinement and is **out of scope for 6.7**. --- ## 7. Observation interaction policy **Policy: minimally interactive — every retained control is a mutation request to the authority; nothing is applied locally.** Strict read-only would be a regression (the dock already exists and is genuinely useful on a second screen). Fully interactive would mean installing hotkeys and local setters on a non-authoritative document. The middle is both correct and already half-built: four of the six dock controls *already* route through `observationEngine.onMutation`. | Control (`index.html`) | Decision | Mechanism | | --- | --- | --- | | `#obs-btn-warp` (WARP) | **Route** | Needs a new target — `view.warp-flight`. Today `toggleWarpFlight()` mutates engine-local state and would diverge. | | `#obs-btn-frame` (VIEWPORT) | **Route** | Existing `view.viewport-frame`; already routed. | | `#obs-btn-pillars` (PILLARS) | **Route** | Needs a new target — `view.pillars`. Today `togglePillars()` is engine-local and would diverge. | | `#obs-btn-alert` (RED ALERT) | **Route** | Existing `alert.active`; already routed. | | `#obs-select-preset` (PRESET) | **Route** | Existing `preset.selected`; already routed. | | `#obs-btn-exit` (RETURN ✕) | **Remove** on the surface | Hidden by CSS. Closing a pane is NGN's job; posting `view.observation=false` from the pane would toggle the *console's* overlay — a confusing cross-surface side effect. | | Background click-to-exit (app.js:4508) | **Remove** on the surface | Same reason; also makes the pane fragile to stray clicks. | | `#observation-return-pill` | **Remove** on the surface | Same. | | HUD HOLD (`#toggle-observation-hud`) | **Surface-local** | A per-screen fade preference, stored only in the checkbox. §31.7 explicitly leaves purely presentational surface-local interaction outside the contract. | | ACTIVITY slider (console panel) | Console-only | Existing `view.activity`; the surface follows it. | | Hotkeys | **Not installed** on the surface | Would be a local mutation path. | Removal is by CSS on the body class, so `observation-engine.js` needs no change — it may keep binding listeners to elements the user can never reach. --- ## 8. Observation lifecycle semantics Four concepts, deliberately kept distinct: | Concept | Owner | Meaning | Changed by | | --- | --- | --- | --- | | **Surface lifecycle** | NGN (`LocalSurfaces`) | Whether an Observation pane exists and is loaded | Operator clicking Open/Close/Reload in NGN | | **`view.observation`** | SciFi console instance | Whether the **console page's own** full-screen overlay is up | WATCH EXPERIENCE, background click, Escape, contract `set` | | **Fullscreen state** | Browser | Whether a document occupies the screen | User/OS; SciFi does not drive it | | **Local overlay CSS state** | Each document | `.active` on `#observation-overlay` | Derived from the two above, per document | ### The explicit answer > **Opening the secondary surface does NOT imply `view.observation = true`.** > "A surface exists" and "Observation mode is active" are separate facts. Rationale: `view.observation` is declared `restorable: false` and is scoped to the console's own presentation. If opening the pane set it true, the operator's console would black out into its overlay the instant they opened the pane — destroying simultaneous control and observation, the whole point of §31.1. Conversely, closing the pane must not set it false, or an operator using the console overlay would lose it when tidying up NGN panes. Contract 5.3 is additive; no existing target may be reinterpreted (`AGENTS.md`), and this keeps `view.observation`'s meaning exactly as it is today. Consequences, all of which 6.7B must honor: - The Observation **surface renders the scene whenever it is open**, with no state flag gating it. It is an Observation surface; that is what it is for. - **WATCH EXPERIENCE is unchanged** — same button, same target, same local overlay. - A host `set view.observation true` still raises the *console's* overlay and is reflected on the surface only as a state readout. It never opens or closes a pane; NGN owns pane lifecycle and Contract 5.3 gives an exhibit no way to ask for one. - Closing the pane detaches cleanly: `window.__xzbtSurfaceDispose` (called synchronously by `local-surfaces.js` before frame removal) plus a `beforeunload` fallback, both idempotent. The owner removes the participant; **no state mutates.** - When the last participant detaches and the console overlay is down, `observationAudienceActive()` goes false and the ambient scheduler stops — the symmetric counterpart of §6's correction. --- ## 9. Standalone behavior Unconditionally preserved, and mostly by construction. - **`index.html` opened with no query string is byte-for-byte today's application.** The mode resolver returns `console` for the absence of `?surface=`, and the console path is the existing code path. No behavior is gated on NGN. - **Host attachment is not part of startup.** It is not today (the adapter only installs a passive `message` listener) and must not become so. No `hello` is ever initiated by SciFi. - **In-page Observation is retained exactly.** WATCH EXPERIENCE, Escape, background click, the dock, and the hotkeys all keep working with no NGN, no surface, and no bus participant. - **The renderer is shared, not duplicated.** Both modes run the same `app.js`/`observation-engine.js` code; only the boot mode differs. There is no second implementation to drift. - **`?surface=observation` is directly usable without NGN** (§31.8's SHOULD): open it in a second browser window over `http(s)` and it attaches to the console tab on the same origin. With no console open it shows a waiting state and never invents its own authority — Museum's proven behavior. - **Single-file build:** opening `SciFiAmbientDisplay_V.html` normally is unchanged. Under `file://`, `BroadcastChannel` is scoped to an opaque origin, so the two-window pairing is **not** promised there; the single-file build's Observation remains the in-page overlay. That is the correct guarantee, and it is exactly what §31.8 requires (primary unconditional, non-primary best-effort). --- ## 10. Packaging approach **No second application, no second document, no build change.** | Concern | Impact | | --- | --- | | Source files | +2 small JS files (`js/surface-mode.js`, `js/surface-bus.js`); edits to `index.html`, `js/app.js`, `js/contract-adapter.js`, `css/style.css`. | | Generated HTML | Still one file, `dist/SciFiAmbientDisplay_V.html`. | | Optional secondary page | **None. Deliberately.** The Observation surface is a query-string view of the same document (§31.4). | | Shared scripts | 100% shared — the surface *is* the same script set. Divergence is structurally impossible. | | Build process | `tools/package.ps1` unchanged. The two new tags are inlined automatically **only if written in the exact form** `` — the regex is whitespace- and quote-sensitive and fails silently otherwise (`agents.md`, "Adding a new JS file"). | | Offline behavior | Unchanged. Nothing new touches the network. `BroadcastChannel` is a browser primitive with no dependency. | | Future embedded offline package | Unaffected — still one artifact. | | Resource duplication | The surface re-parses the same cached bytes and constructs the inert subsystem shells; it downloads nothing new and runs no second engine. | Script order in `index.html` — insert **after `control-bus.js`, before `generative-experience.js`**, so the adapter and `app.js` can see both globals: ``` audio.js → config.js → core-animations.js → visualizer.js → observation-bezels.js → observation-engine.js → control-bus.js → surface-mode.js → surface-bus.js → generative-experience.js → contract-adapter.js → app.js ``` --- ## 11. Contract adapter changes All in `js/contract-adapter.js`. Additive only. No change to the handshake, the envelope, message types, or generic semantics. **1. Surface catalog.** Accept `options.surfaces` (an array) and `options.instanceId`; store them. Emit `surfaces` from `describe()` **only when non-empty**, so absence stays §31.3 form 1: ```js describe() { const description = { exhibit: {...}, contract: {...}, registryRevision, stateRevision, capabilities: [...], targets: [...] }; if (this.surfaces && this.surfaces.length) description.surfaces = this.surfaces; return description; } ``` The array is a plain static declaration with exactly one `primary: true`. Host-side §31.3 validation is NGN's job, not the exhibit's; do not reimplement it. **2. Two missing absolute state targets.** Required for deterministic presentation sync — without them, PILLARS and WARP diverge between console and surface, and a host cannot read or restore them at all: ```js reg({ id: 'view.pillars', legacyId: 'observation-pillars', label: 'Window Pillars / Mullions', kind: 'state', valueType: 'boolean', readable: true, writable: true, restorable: true, category: 'observation', requires: ['observation'] }); reg({ id: 'view.warp-flight', label: 'Warp Flight Mode', kind: 'state', valueType: 'boolean', readable: true, writable: true, restorable: true, category: 'observation', requires: ['observation'] }); ``` Permitted by §28.7 (additive targets). Wire matching entries in `bindings.setters` (→ `observationEngine.setPillars` / `setWarpFlight`) and in `getContractStateSnapshot()` (→ `observationEngine.showPillars` and the engine's warp-flight field — confirm its exact name when implementing). `registryRevision` stays `1`; this is a new build, not a runtime registry change, so no `registry.changed` is emitted. **3. Session-independent local hooks.** Add `onLocalChange` / `onLocalAction` as described in §5.4. This is the only structural addition and it changes no contract-visible behavior. **4. Identity.** `app.js:126` passes `version: '5.2.0'`; bump to `'5.3.0'` to match the adapter's declared `contractMinor = 3` and this build's surface support. Advisory metadata only. **No second command protocol.** Surface-originated interactions reuse `set` / `invoke` semantics through `applyMutation` / `invokeAction`. **No capability change** — `observation` is already declared `ready`. --- ## 12. NGN changes ### None. Verified against the current source rather than assumed: - **Query-string surface URLs already work.** `src/surface-url.js` accepts a relative reference with a query and rejects only schemes, protocol-relative and cross-origin values. `tests/local-surfaces.test.js:153` is literally *"a separate primary page and query/fragment views are rendered from generic descriptors"* — the case SciFi needs is already covered. - **The resource check passes.** `server/serve.js` routes on `url.pathname` and ignores the query, so `HEAD index.html?surface=observation` returns 200 and `checkSurfaceResource` is satisfied. - **Primary reuse already works.** `src/local-surfaces.js:35` compares full URLs and marks the matching primary as the control pane, so it is never re-opened. `tests/local-surfaces.test.js:54` covers it with Museum's real descriptors. - **Lifecycle already works.** `release()` calls `__xzbtSurfaceDispose` synchronously before removing a frame (`src/local-surfaces.js:51`); SciFi implements the hook. - **Catalog validation already works.** `host.js:154` runs `validateSurfaceCatalog` against §31.3 ordering, exercised by `tests/museum-gallery.test.js:172–253`. If a genuine NGN gap emerges during 6.7B, it must be fixed **generically** and justified against Museum Gallery; a SciFi-specific branch is not an acceptable outcome (`AGENTS.md`: "Do not infer XZBT-NGN behavior from SciFi-XZBT source code"). --- ## 13. File-by-file implementation plan ### A. SciFi-XZBT — `G:/.vibe/SciFi-XZBT` (the real exhibit) | # | File | Why it changes | Responsibility added | Must not change | | --- | --- | --- | --- | --- | | A1 | `js/surface-mode.js` **(new, ~60 lines)** | A pure, testable seam for mode resolution and the catalog | `XZBTSurfaceMode.resolve(locationLike) → { mode, instanceId }`; `XZBTSurfaceMode.newInstanceId()`; `XZBTSurfaceMode.SURFACES(instanceId)`; `XZBTSurfaceMode.channelName(instanceId)` | No DOM, no globals beyond `window.XZBTSurfaceMode`, no imports. Unknown `?surface=` values resolve to `console`. | | A2 | `js/surface-bus.js` **(new, ~180 lines)** | Exhibit-internal state mirror | `createOwner(adapter, { channelName, onParticipantsChanged })` and `attach({ channelName, timeoutMs, onSnapshot, onChange, onAction, onPresentation, onTimeout })`, plus idempotent `detach()` and `mutate(kind, target, valueOrArgs)` | Carries no XZBT envelope. `Set`-based participant bookkeeping (not a counter). Owner routes every `mutate` through `adapter.applyMutation/invokeAction` with source `'ui'` — never a second computation of state. | | A3 | `index.html` | Load the two new files | Two `