One commit for the work accumulated in the working tree since Step 6.3, which had never been split into per-step commits: - src/local-surfaces.js + src/surface-url.js (new); src/ui.js, src/validation.js, src/connection.js and public/index.html updated for local-surface hosting and generic surface rendering - tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7) new; connection/museum-gallery/surface-validation suites updated - reference exhibits: shared/contract-core.js defaults to Contract 5.3 (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface catalog; aquarium/haunted-house/planetarium adapters updated - SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js, Observation-surface boot branch, local-change hooks, view.pillars / view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT - SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on its presence/type, matching the host's own envelope validation; the adapter now advertises contract minor 3 / version 5.3.0, which it already implemented via the 5.3 surfaces field. Root cause of the five failing postmessage-interop tests (host hello was silently dropped). - docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs; test-fixtures/PROVENANCE.md resync record Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7); SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check clean for changed files; two pre-existing trailing-whitespace lines remain in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from the authoritative SciFi source. Step 6.7 live verification (browser Observation, packaged standalone) is still pending and is not claimed here.
55 KiB
Step 6.7A — SciFi Observation Surface Architecture
Status: design only. No implementation performed. No source files modified. Scope: adapting SciFi-XZBT Observation to the already-proven Contract 5.3 local presentation-surface model (Steps 6.3–6.6). Audience: the implementation model executing Step 6.7B.
Repositories inspected
| Repo | Path | Role |
|---|---|---|
| XZBT-NGN | G:/.vibe/XZBT-NGN |
Host engine, contract spec, test fixtures |
| SciFi-XZBT | G:/.vibe/SciFi-XZBT |
The real exhibit source. Step 6.7B edits here. |
test-fixtures/reference-exhibits/scifi/ inside XZBT-NGN is a copy of
SciFi-XZBT, kept for NGN's own tests (test-fixtures/PROVENANCE.md). Step 6.7B
must change SciFi-XZBT and then resync the fixture. Both working trees carry
uncommitted work; nothing was modified by this task.
1. Recommendation
Adopt Candidate A in its narrowest form: one document, one script set, two boot
modes. SciFi advertises exactly two surfaces. The primary is the existing
index.html (unchanged, the full console). The Observation surface is
the same document re-served with a query string — ?surface=observation&xi=<instanceId>
— which Contract 5.3 §31.4 explicitly provides for ("a bare query string and/or
fragment, resolving against the exhibit's own base document, for a single-page
exhibit whose surfaces are views within one already-served document"). A boot-mode
resolver read at the top of app.js decides which mode the instance runs in. In
Observation mode the instance constructs no audio graph, no generative engine, no
contract host bridge, and no ambient event scheduler; it renders the existing
#observation-overlay and mirrors state from the authoritative console instance
over an exhibit-internal, instance-scoped BroadcastChannel.
This is the only candidate that survives contact with the actual code. Roughly
3,230 of app.js's 4,703 lines (lines ~1261–4494) are the Observation renderer,
and they live inside app.js's single IIFE closure, reading activeUniverseId,
activePresetId, isPlaying, observationActivity, UniverseRegistry,
observationEngine, alerts and audioManager as free variables.
observation-engine.js additionally reads five console-panel element IDs
(toggle-observation-viewport, val-observation-viewport,
toggle-observation-pillars, val-observation-pillars, and the preset select)
that do not exist outside the console document. Keeping one document means zero
renderer extraction and zero DOM decoupling: every element the renderer reaches
for is still present, merely hidden by a body class. A dedicated lightweight page
(Candidate B) would require lifting that entire closure into a shared module —
the single largest and riskiest refactor available in this project — and would
still not fit the distribution, because tools/package.ps1 inlines exactly one
input document and emits exactly one output file. A second HTML document simply
cannot exist in the standalone single-file build.
The second load-bearing decision: surface existence is not view.observation.
view.observation keeps its current meaning exactly — the console page's own
full-screen overlay — and opening or closing the NGN Observation pane never
touches it. Conflating them would black out the operator's console the moment
they opened the presentation pane, which is precisely the simultaneity failure
Candidate C is rejected for. The Observation surface renders the scene
unconditionally while it is open; that is its whole purpose and it needs no state
flag to say so.
2. Current SciFi state architecture
2.1 Where state actually lives
app.js is one DOMContentLoaded IIFE. There is no state object. Authority is
distributed across subsystem instances and three closure variables, and
getContractStateSnapshot() (app.js:4666) is the one function that composes them
into the contract-visible view.
| Contract state | Authoritative owner | Read path |
|---|---|---|
universe.selected |
let activeUniverseId (app.js:29) |
closure var |
preset.selected |
let activePresetId (app.js:30) |
closure var |
transport.playing |
let isPlaying (app.js:28) |
closure var |
transport.muted, mix.master |
AudioManager |
audioManager.isMuted / .getMasterVolume() |
mix.hull.* |
HullDroneSynth.params |
hullDrone.params.* |
mix.drive.* |
WarpCoreSynth.params |
warpCore.params.* |
mix.environment.* |
LifeSupportSynth.params |
lifeSupport.params.* |
mix.telemetry.* |
TelemetrySynth.params |
telemetry.params.* |
fnc.enabled, fnc.level |
FuturisticNoiceCancellation |
fncSystem.enabled / .level |
speech.robot-amount |
XZBTGenerativeExperience |
generativeExperience.roboticAmount() |
alert.active |
AlertSynth.activeAlert |
alerts.activeAlert |
view.observation |
let observationActive (app.js:1281) |
closure var |
view.activity |
let observationActivity (app.js:1282) |
closure var |
view.viewport-frame |
ObservationEngine.showViewport |
observationEngine.showViewport |
showPillars (no target) |
ObservationEngine.showPillars |
— |
| warp flight (no target) | ObservationEngine internal |
— |
| HUD hold (no target) | console checkbox DOM only | — |
Canonical vs reflected. The subsystem/closure values above are canonical. All
DOM is reflected: every setter in the adapter's bindings.setters writes the
subsystem first and then assigns slider.value / label.textContent. There is
exactly one genuine exception — HUD hold is stored nowhere but
#toggle-observation-hud.checked, which is why it is treated as surface-local in
§7 rather than promoted to a target.
Observation does not own its own mutation path. observationEngine.onMutation
(app.js:259) routes every dock interaction straight into
contractAdapter.applyMutation(...) with source 'ui' — except togglePillars()
and toggleWarpFlight(), which mutate engine-local fields directly because no
canonical target exists for them (see §11).
The adapter reads the same authority. bindings.getState is literally
() => getContractStateSnapshot(). There is no second state computation anywhere.
2.2 Flow diagram (actual)
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) — callsgenerativeExperience.prepareExperience()(the WebLLM/Kokoro loader), setsobservationActive = true, adds.activeto#observation-overlay, callsrefreshObservation(), starts the HUD/return fade timers, and callsscheduleObservationAmbientActivity().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) — injectsgetObservationSvg(activeUniverseId), starts the scene and instrumentation, and callsobservationEngine.start(activeUniverseId).- Every entry/exit path (button app.js:4502, background click 4508, return pill 4517,
Escape 4574) goes through
applyMutation('view.observation', …). Nothing togglesobservationActivedirectly. This is already clean.
2.4 Assets, packaging and existing surface concepts
index.htmlloads one stylesheet and ten scripts in a fixed order, all classic globals.agents.mdforbids ES modules, dependencies and network access outside the one boundedgenerative-experience.jsexception.tools/package.ps1walksindex.htmlline by line and inlines any<script src="…"></script>or<link rel="stylesheet" href="…">that matches its exact regex, emitting one file,dist/SciFiAmbientDisplay_V<commit>.html. One input document, one output document. There is no other build step.- No existing surface concept.
describe()(contract-adapter.js:854) returnsexhibit / contract / registryRevision / stateRevision / capabilities / targetsand nosurfacesfield. 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) anddocs/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.ps1produces 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 thatagents.mdmarks "surgical, scoped edits — do not rewrite", plus decouplingobservation-engine.jsfrom 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/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:
iduses the canonical target-ID grammar (§8.1) and matches Museum'ssurface.control/surface.info-wallconvention.kind: 'surface'is the required constant. Exactly oneprimary: 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-declaredobservationcapability (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
consoleis the sole authority. It owns every subsystem, the one contract session, and the one mutation chokepoint. An instance whose mode isobservationowns nothing: it holds a read-only mirror and may only request mutations.
The presentation instance never calls contractAdapter.applyMutation,
invokeAction, or any bindings.setter. It never constructs a contract session:
it does not install the window message bridge, so a stray hello cannot reach it,
and NGN never offers one — LocalSurfaces attaches no transport to surface frames
(src/local-surfaces.js, and tests/local-surfaces.test.js:232 asserts a
presentation window cannot impersonate an authoritative peer).
5.2 Mechanism: instance-scoped BroadcastChannel
Chosen mechanism: BroadcastChannel, named
xzbt-scifi-surface-v1:<instanceId>, with the console document as owner —
Museum Gallery's proven shape (surface-bus.js), with one deliberate improvement.
Evaluated and rejected:
- Explicit parent/child
postMessage. Structurally impossible here. Under NGN the console document and the Observation iframe are siblings inside the NGN page, not parent and child. Neither holds a window handle to the other. This alone decides the mechanism. storageevents / 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 participantIds, 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:
// 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 livecontractAdapter.getContractState()plus transient presentation extras. - Reload: a reload is a fresh
attachwith a freshparticipantId; identical path to first join. - Prompt propagation:
onLocalChangefires inside_commitChanges, i.e. in the same turn as the mutation. - Surface interactions: routed as
mutateand executed by the owner throughcontractAdapter.applyMutation(..., 'ui')— the same chokepoint NGN'ssetuses, 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:
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 truestill 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 bylocal-surfaces.jsbefore frame removal) plus abeforeunloadfallback, 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.htmlopened with no query string is byte-for-byte today's application. The mode resolver returnsconsolefor 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
messagelistener) and must not become so. Nohellois 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.jscode; only the boot mode differs. There is no second implementation to drift. ?surface=observationis directly usable without NGN (§31.8's SHOULD): open it in a second browser window overhttp(s)and it attaches to the console tab on the same origin. With no console open it shows a waiting state and never invents its own authority — Museum's proven behavior.- Single-file build: opening
SciFiAmbientDisplay_V<commit>.htmlnormally is unchanged. Underfile://,BroadcastChannelis scoped to an opaque origin, so the two-window pairing is not promised there; the single-file build's Observation remains the in-page overlay. That is the correct guarantee, and it is exactly what §31.8 requires (primary unconditional, non-primary best-effort).
10. Packaging approach
No second application, no second document, no build change.
| Concern | Impact |
|---|---|
| Source files | +2 small JS files (js/surface-mode.js, js/surface-bus.js); edits to index.html, js/app.js, js/contract-adapter.js, css/style.css. |
| Generated HTML | Still one file, dist/SciFiAmbientDisplay_V<commit>.html. |
| Optional secondary page | None. Deliberately. The Observation surface is a query-string view of the same document (§31.4). |
| Shared scripts | 100% shared — the surface is the same script set. Divergence is structurally impossible. |
| Build process | tools/package.ps1 unchanged. The two new tags are inlined automatically only if written in the exact form <script src="js/surface-mode.js"></script> — the regex is whitespace- and quote-sensitive and fails silently otherwise (agents.md, "Adding a new JS file"). |
| Offline behavior | Unchanged. Nothing new touches the network. BroadcastChannel is a browser primitive with no dependency. |
| Future embedded offline package | Unaffected — still one artifact. |
| Resource duplication | The surface re-parses the same cached bytes and constructs the inert subsystem shells; it downloads nothing new and runs no second engine. |
Script order in index.html — insert after control-bus.js, before
generative-experience.js, so the adapter and app.js can see both globals:
audio.js → config.js → core-animations.js → visualizer.js →
observation-bezels.js → observation-engine.js → control-bus.js →
surface-mode.js → surface-bus.js →
generative-experience.js → contract-adapter.js → app.js
11. Contract adapter changes
All in js/contract-adapter.js. Additive only. No change to the handshake, the
envelope, message types, or generic semantics.
1. Surface catalog. Accept options.surfaces (an array) and
options.instanceId; store them. Emit surfaces from describe() only when
non-empty, so absence stays §31.3 form 1:
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:
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.jsaccepts a relative reference with a query and rejects only schemes, protocol-relative and cross-origin values.tests/local-surfaces.test.js:153is 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.jsroutes onurl.pathnameand ignores the query, soHEAD index.html?surface=observationreturns 200 andcheckSurfaceResourceis satisfied. - Primary reuse already works.
src/local-surfaces.js:35compares full URLs and marks the matching primary as the control pane, so it is never re-opened.tests/local-surfaces.test.js:54covers it with Museum's real descriptors. - Lifecycle already works.
release()calls__xzbtSurfaceDisposesynchronously before removing a frame (src/local-surfaces.js:51); SciFi implements the hook. - Catalog validation already works.
host.js:154runsvalidateSurfaceCatalogagainst §31.3 ordering, exercised bytests/museum-gallery.test.js:172–253.
If a genuine NGN gap emerges during 6.7B, it must be fixed generically and
justified against Museum Gallery; a SciFi-specific branch is not an acceptable
outcome (AGENTS.md: "Do not infer XZBT-NGN behavior from SciFi-XZBT source code").
13. File-by-file implementation plan
A. SciFi-XZBT — G:/.vibe/SciFi-XZBT (the real exhibit)
| # | File | Why it changes | Responsibility added | Must not change |
|---|---|---|---|---|
| A1 | js/surface-mode.js (new, ~60 lines) |
A pure, testable seam for mode resolution and the catalog | XZBTSurfaceMode.resolve(locationLike) → { mode, instanceId }; XZBTSurfaceMode.newInstanceId(); XZBTSurfaceMode.SURFACES(instanceId); XZBTSurfaceMode.channelName(instanceId) |
No DOM, no globals beyond window.XZBTSurfaceMode, no imports. Unknown ?surface= values resolve to console. |
| A2 | js/surface-bus.js (new, ~180 lines) |
Exhibit-internal state mirror | createOwner(adapter, { channelName, onParticipantsChanged }) and attach({ channelName, timeoutMs, onSnapshot, onChange, onAction, onPresentation, onTimeout }), plus idempotent detach() and mutate(kind, target, valueOrArgs) |
Carries no XZBT envelope. Set-based participant bookkeeping (not a counter). Owner routes every mutate through adapter.applyMutation/invokeAction with source 'ui' — never a second computation of state. |
| A3 | index.html |
Load the two new files | Two <script src> tags in the exact package.ps1 form, positioned per §10 |
No DOM changes. The overlay markup, dock and all IDs stay exactly as they are — the design depends on them existing in both modes. |
| A4 | js/contract-adapter.js |
Advertise surfaces; close the two target gaps; expose session-independent hooks | §11 items 1–4 | Handshake, envelope, xzbt advisory handling, inbound gating, error codes, _emitEvent's session gate, existing target IDs and semantics. |
| A5 | js/app.js |
Mode branch, owner/mirror wiring, audience scheduler | (a) resolve mode at the top; add body.surface-observation in presentation mode. (b) Presentation branch skips: XZBTGenerativeExperience construction/bind, StarshipVisualizer, CoreAnimations, XZBTControlBus, XZBTContractAdapter, the keydown handler, the sleep timer, and all console-panel listeners. (c) enterObservation() must not call prepareExperience() in presentation mode. (d) Presentation enters the scene on snapshot and re-runs refreshObservation() on universe/preset/activity change. (e) Replace observationActive with observationAudienceActive() for scheduling/deciding activity only. (f) Broadcast activity decisions and ticker text; presentation renders them and runs no scheduler. (g) Presentation dock routes through bus.mutate. (h) window.__xzbtSurfaceDispose = () => link.detach() + beforeunload. (i) Console mode constructs the bus owner and feeds it from onLocalChange/onLocalAction. |
The console code path's behavior. getContractStateSnapshot()'s shape (extend with the two new keys only). The single mutation chokepoint. agents.md: surgical scoped edits, no reformatting. |
| A6 | css/style.css |
Present the surface mode | body.surface-observation: hide header/main/footer, show the overlay full-bleed, hide #obs-btn-exit and #observation-return-pill |
Existing theme, overlay and CRT rules. Additive block only. |
| A7 | agents.md |
Keep the agent contract honest | Document the one-document/two-mode rule, the two new files, the bus, and the "never construct audio/AI in presentation mode" invariant | The hard constraints; they are all still satisfied. |
| A8 | README.md |
User-facing | One short paragraph on the Observation surface | — |
Deliberately unchanged: js/observation-engine.js, js/observation-bezels.js,
js/audio.js, js/config.js, js/visualizer.js, js/core-animations.js,
js/generative-experience.js, js/control-bus.js, tools/package.ps1, dist/**
(regenerate, never edit).
B. XZBT-NGN — G:/.vibe/XZBT-NGN
| # | File | Why it changes | Responsibility added | Must not change |
|---|---|---|---|---|
| B1 | test-fixtures/reference-exhibits/scifi/** |
Fixture must match the new exhibit | Mechanical resync of the changed files + the two new ones | Nothing hand-edited; keep it a faithful copy. |
| B2 | test-fixtures/PROVENANCE.md |
Provenance discipline | Note the resync, its source commit, and that no fixture-local corrections were added | The existing correction record. |
| B3 | tests/scifi-surfaces.test.js (new) |
Cover the new seams | §14 tests, in the existing node:test + vm.createContext + real BroadcastChannel style (tests/museum-gallery.test.js) |
— |
| B4 | tests/local-surfaces.test.js |
Prove the real SciFi descriptors render correctly | One case using SciFi's actual catalog, mirroring the Museum cases at :54 and :153 | Existing cases. |
| B5 | docs/reference/XZBT-NGN-Step6.7-SciFi-Surface-Adaptation.md (new) |
Verification writeup | Evidence for 6.7B, matching the Step 6.6 document's shape | — |
| B6 | README.md |
Operator accuracy | Note that NGN should be pointed at the explicit …/scifi/index.html URL (risk R1) |
— |
src/** — no changes. If a diff appears there in 6.7B, it is a design
deviation and needs review before merge.
14. Automated test plan for Step 6.7B
node --test, matching the existing style: vm.createContext per simulated
document, Node's real BroadcastChannel, plain() for cross-realm comparison,
and every channel closed in teardown. app.js is far too DOM-bound for vm;
that is why A1/A2 are deliberately small, pure and separately loadable, and why
the app-level invariants below are asserted as source-structure tests — the
same technique as Museum's "Exactly one Exhibit State Core exists".
Catalog and contract (tests/scifi-surfaces.test.js)
describe()includes asurfacesarray that passesvalidateSurfaceCatalogagainst a representative base URL.- Exactly one entry has
primary: true. - Every entry carries only §31.2 fields; no invented fields;
kind === 'surface'. - Constructing the adapter with no
surfacesoption omits the field entirely (§31.3 form 1 preserved — the Museum analogue atmuseum-gallery.test.js:148). resolveSurfaceURL('?surface=observation&xi=…', base)resolves same-origin tobase?…; absolute/protocol-relative variants are rejected.- The primary's
urlresolves to exactly a base of…/scifi/index.html, soLocalSurfacesmarks itcontroland opens no second frame (extendtests/local-surfaces.test.js). view.pillarsandview.warp-flightexist, arestate/boolean, readable, writable, and appear in the snapshot key set.
Mode resolution (pure)
- No query →
console.?surface=observation→observation.?surface=bogus→console. Query order and extra params are tolerated. xiround-trips throughSURFACES()→resolve()andchannelName()is instance-scoped (two ids yield different names).
Bus / synchronization
- Late join: mutate first, then attach — the snapshot carries current
universe, preset and view state, plus the current
stateRevision. - Propagation: an authority mutation reaches an attached surface as one
state/selectionmessage with the post-mutation value. - Mutation routing:
link.mutate('set','view.pillars',true)results in exactly oneadapter.applyMutationcall with source'ui', and the value converges on the authority and on a second attached surface. - No second authority: a surface-side
mutateperforms no local state write; with no owner present it is a no-op and the surface enters the waiting state (the analogue ofmuseum-gallery.test.js:469). - One event stream:
stateRevisionadvances monotonically regardless of which surface originated the interaction; two attached surfaces never disagree. - Reload: re-attaching with a fresh
participantIdyields the current snapshot; repeated reloads leave the participant count stable (no ratchet). - Detach: idempotent; never mutates state; count decrements exactly once;
duplicate/late
detachcannot underflow. - Session independence: with
sessionActive === false,onLocalChangestill fires on_commitChanges— the correction in §5.4 — while_emitEventstill emits nothing.
Duplicate-subsystem prevention (construction spies + source structure)
- Spy test: load
surface-bus.jsin avmcontext with stub globals and assert the surface side never touchesAudioContext,fetchorimport— a counting stub forglobalThis.AudioContextmust record zero constructions. - Source-structure test: assert
js/app.jscontains no call toprepareExperience()that is not inside the console-mode branch — pin it by asserting the presentation branch is entered before thenew XZBTGenerativeExperience/new XZBTContractAdapter/new StarshipVisualizerconstruction sites, in the manner of Museum's "structurally incapable" assertion. Prefer a resilient marker (e.g. a namedbootConsole()function containing those constructions) over brittle regex — A5 should be implemented so this test is easy to write. - Timer test: with only a presentation instance simulated, no ambient-activity timer is scheduled.
- One contract session: the presentation branch never installs a
messagelistener; a simulatedhelloposted at it produces nohello.result.
Regression
tests/museum-gallery.test.js,tests/local-surfaces.test.js,tests/surface-validation.test.js,tests/host.test.js,tests/connection.test.js,tests/postmessage-interop.test.js,tests/haunted-house.test.jsall remain green (npm test).tests/postmessage-interop.test.js:118still negotiates a SciFi session against the resynced fixture.
15. Live acceptance procedure
Serve with npm start (http://127.0.0.1:4173).
- Open NGN at
http://127.0.0.1:4173/and connect tohttp://127.0.0.1:4173/test-fixtures/reference-exhibits/scifi/index.html(the explicit filename — see R1). - Confirm
connected, Contract major 5 / minor 3, target catalog populated. - In the Surfaces panel: exactly two entries.
surface.consoleis badged Primary and offers no Open button (it is the control pane).surface.observationis non-primary withURL: ?surface=observation&xi=…. - Open
surface.observation. It loads in its own pane and renders the current universe and preset within ~1s. - Confirm the pane shows no RETURN ✕ and no return pill, and that clicking its background does nothing.
- Confirm the console pane is still the full console —
view.observationremainsfalseand the console did not black out. (This is the §8 invariant.) - From NGN,
set universe.selectedto another universe. Both the console and the Observation pane change scene. Repeat forpreset.selected,alert.active,view.viewport-frame,view.activity. - On the Observation pane, click PILLARS and WARP. The value changes on
the pane and on the console's own controls, and NGN's state readout advances
stateRevision— proving the mutation went through the authority, not locally. - Click RED ALERT and change PRESET on the pane; confirm the same convergence across console, pane and NGN.
- Reload the Observation pane from NGN. It returns to the current state, not a default one.
- Close the pane, then reopen it. Same result; the participant count in the console does not ratchet.
- On the console, press WATCH EXPERIENCE. The console overlay comes up,
view.observationbecomestrue, and the Observation pane is unaffected (it was already rendering). - Exit the console overlay.
view.observationreturns tofalse; the pane is still rendering. Repeat once. - Duplication checks, with the pane open and the console engaged (audio on):
- DevTools → the pane's frame → console shows no WebLLM/Kokoro network
activity, and
window.generativeExperienceisundefined. - The pane's frame has no
AudioContext(audio comes only from the console); mute the console and the room goes fully silent. - Transient activity events (contacts, rings, cards) appear on console overlay and pane at the same moment and as the same events, not independently.
- An AI announcement appears once, on both tickers, with identical text.
- DevTools → the pane's frame → console shows no WebLLM/Kokoro network
activity, and
- Disconnect NGN. The pane is released and the console continues standalone. Reconnect; surfaces rediscover and the pane reopens cleanly.
- Standalone, no NGN: open
…/test-fixtures/reference-exhibits/scifi/index.htmldirectly. Full console, clean browser console, WATCH EXPERIENCE works, hotkeys work, audio engages. - Standalone two-window: with that tab open, open
…/index.html?surface=observation&xi=<id from the console's describe()>in a second window. It attaches and mirrors with no NGN present. - Open
…/index.html?surface=observation&xi=nonexistentalone. It shows a waiting state and does not invent its own state or start audio. - Re-run
tools/package.ps1; open the newdist/file with the network disabled. Console, Observation overlay, audio and hotkeys all work; the Generative Experience panel degrades quietly. (The packaged build's Observation is the in-page overlay; two-window pairing is not promised underfile://— §9.)
16. Risks and mitigations
R1 — A directory-style base URL causes the primary to open a second full
instance. LocalSurfaces uses full-URL equality. If the operator connects to
…/scifi/ rather than …/scifi/index.html, exhibitBaseUrl ends in /,
index.html no longer equals it, and NGN offers to open the primary as a pane —
which would load a second complete SciFi app.
Impact: bounded. That instance gets no transport from NGN and its window bridge
only answers parent/opener/self, so no second contract session is created, and
it creates no AudioContext until someone engages it. It is wasted, not wrong.
Mitigation: document the explicit-filename URL in README.md (B6) and in the
6.7B verification doc; it is already the documented URL. Do not "fix" this in
NGN — full-URL equality is deliberate (queries and fragments designate other
views), and Museum has the same property. Revisit generically in a later step if
it proves to bite operators.
R2 — The prepareExperience() guard is missed. enterObservation() calls it
unconditionally today; a presentation instance that reaches that line downloads a
1.7B model per pane.
Mitigation: the single most important guard in A5(c); covered by tests 18–19 and
acceptance step 14. Structure A5 so the construction sites sit in a named
console-only function, making the assertion cheap and durable.
R3 — The ambient scheduler silently never runs. Both
triggerObservationActivity and scheduleObservationAmbientActivity early-return
on !observationActive; with the console in normal mode, an open pane would show
a static scene and the defect would look like "the surface is just quiet".
Mitigation: the observationAudienceActive() correction (§6), test 20, and
acceptance step 14's "same moment, same events" check, which fails loudly if
nothing is generated.
R4 — Mode branching damages the console path. app.js is 4,703 lines of
tightly coupled closure; a badly placed branch could disable console features.
Mitigation: branch by skipping construction in presentation mode rather than
by adding conditionals through the console path; agents.md's surgical-edit rule;
acceptance steps 16–19 exercise standalone fully; keep the diff reviewable.
R5 — BroadcastChannel cross-talk between instances. Two console tabs on one origin would share a fixed channel name. Mitigation: the instance-scoped channel name (§5.2), tests 9 and 18.
R6 — Fixture drift between SciFi-XZBT and the NGN copy. Two trees, one
mechanical copy, and both currently carry uncommitted work.
Mitigation: resync as one discrete step (B1), record it in PROVENANCE.md (B2),
and hash-verify as Step 5 did (test-fixtures/evidence/step5-scifi-files.json).
R7 — package.ps1's silent inlining failure. A script tag with different
quoting, extra attributes or self-closing form is left as an external reference and
the standalone build breaks with no error.
Mitigation: A3 specifies the exact form; acceptance step 19 opens the packaged
build offline, which is the only test that catches it.
R8 — Audio-reactive visuals are flat on the surface. No analyser without an
audio graph.
Mitigation: accepted and documented (§6). ObservationEngine already guards it.
Mirroring a scalar level is deferred; do not solve it in 6.7B.
Explicitly not risks: a second contract session (structurally prevented twice over); NGN genericity (no NGN change); loss of standalone behavior (the console path is untouched); packaging fork (one document by construction).
17. Step 6.7B scope estimate
Moderate.
Not small: the work spans two repositories, touches the largest and most
sensitive file in SciFi-XZBT (app.js), adds two contract targets and a catalog,
introduces two new source files and a new synchronization mechanism, and needs a
fixture resync plus two test files and a verification document. Three of the
corrections it depends on — the _emitEvent session gate, the
prepareExperience() call inside enterObservation(), and the
observationActive gate on the ambient scheduler — are non-obvious and would each
produce a plausible-looking but wrong implementation if missed.
Not large: the decisive cost driver was avoided. There is no renderer
extraction, no DOM decoupling, no second document, no build change, and no NGN
change. observation-engine.js (3,595 lines) and audio.js (6,889 lines) are
untouched. The synchronization mechanism is a direct adaptation of Museum
Gallery's already-validated surface-bus.js, and the NGN side was proven in Steps
6.3–6.6.
Expected diff: roughly 240 new lines across two new SciFi files, ~120 modified
lines in app.js, ~40 in contract-adapter.js, ~25 in css/style.css, 2 lines
in index.html, plus ~350 lines of new tests and the fixture resync.
The risk concentrates almost entirely in A5. Implement A1 and A2 first, test them
in isolation, and only then branch app.js.