diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 51d9a3d..c86b57a 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost { private submit; private consumeCommandResult; private presentCommandStatus; + private loweringOptions; } export interface EveEmbeddedDocumentRequest { documentId: string; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 7d9b624..ee41823 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -111,19 +111,7 @@ export class EveBrowserProviderHost { if (version === this.lastSurfaceVersion) return; this.lastSurfaceVersion = version; - renderEveSurface(surface, this.host, { - activeSurfaceId: this.selected.surfaceId, - assetUrlResolver: this.transport.resolveAssetUrl, - body: this.options.body, - clientId: this.options.clientId || "eve-browser", - commandSink: intent => this.submit(intent), - documentResolver: this.transport.resolveDocument, - provider: this.provider, - pluginAdapters: this.pluginAdapters, - source: this.options.source, - statusElement: this.options.statusElement, - draftStore: this.draftStore, - }); + renderEveSurface(surface, this.host, this.loweringOptions()); } catch (error) { if (this.options.statusElement) { @@ -171,15 +159,24 @@ export class EveBrowserProviderHost { region.replaceChildren(el("p", "eve-command-result-message", message)); const projection = transient; if (projection?.surface?.root) { - region.append(renderEveComponent(projection.surface.root, { - activeSurfaceId: projection.surface.id || this.selected?.surfaceId, - clientId: this.options.clientId, - draftStore: this.draftStore, - pluginAdapters: this.pluginAdapters, - provider: this.provider, - })); + region.append(renderEveComponent(projection.surface.root, scopeEveBrowserOptions(projection, this.loweringOptions()))); } } + loweringOptions() { + return { + activeSurfaceId: this.selected?.surfaceId, + assetUrlResolver: this.transport.resolveAssetUrl, + body: this.options.body, + clientId: this.options.clientId || "eve-browser", + commandSink: intent => this.submit(intent), + documentResolver: this.transport.resolveDocument, + provider: this.provider, + pluginAdapters: this.pluginAdapters, + source: this.options.source, + statusElement: this.options.statusElement, + draftStore: this.draftStore, + }; + } } function requireCommandCapability(surface) { if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) { diff --git a/packages/eve-browser-lowering/docs/host-authority.md b/packages/eve-browser-lowering/docs/host-authority.md index 2a8a6bb..7acbb41 100644 --- a/packages/eve-browser-lowering/docs/host-authority.md +++ b/packages/eve-browser-lowering/docs/host-authority.md @@ -28,6 +28,9 @@ Derived state: - local drafts and the latest command-result region are renderer-owned transients; refresh may replace the surface projection but may not erase a receipt before the player can perceive it; +- a provider-owned transient projection inherits the selected logical + surface's advertised command boundary while retaining its own command + descriptors and component context; - loaded font stylesheets are a document resource cache, not active-surface state. @@ -42,6 +45,8 @@ Shared paths: - initial render, embedded render, plugin render, binding updates, command creation, and asset resolution all receive the same host-scoped context; +- retained and command-result projections use the same host transport options; + a transient presentation surface ID never becomes command-routing authority; - initial binding hydration and live binding watches use the same component projection replacement primitive. - operation capture resolves edited drafts first and otherwise uses the @@ -63,4 +68,6 @@ Verification: - untouched select and numeric defaults are present in captured operations; - accepted and denied command results remain visible after authoritative refresh; +- controls inside a transient command-result projection invoke its descriptor + through the selected provider surface and canonical command sink; - disposing or rerendering one host cannot unsubscribe the other. diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index efa7e8a..335e173 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -269,19 +269,7 @@ export class EveBrowserProviderHost { const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`; if (version === this.lastSurfaceVersion) return; this.lastSurfaceVersion = version; - renderEveSurface(surface, this.host, { - activeSurfaceId: this.selected.surfaceId, - assetUrlResolver: this.transport.resolveAssetUrl, - body: this.options.body, - clientId: this.options.clientId || "eve-browser", - commandSink: intent => this.submit(intent), - documentResolver: this.transport.resolveDocument, - provider: this.provider, - pluginAdapters: this.pluginAdapters, - source: this.options.source, - statusElement: this.options.statusElement, - draftStore: this.draftStore, - }); + renderEveSurface(surface, this.host, this.loweringOptions()); } catch (error) { if (this.options.statusElement) { this.options.statusElement.textContent = error instanceof Error ? error.message : "Eve surface unavailable."; @@ -334,15 +322,28 @@ export class EveBrowserProviderHost { region.replaceChildren(el("p", "eve-command-result-message", message)); const projection = transient as EveSurfaceDocument | undefined; if (projection?.surface?.root) { - region.append(renderEveComponent(projection.surface.root, { - activeSurfaceId: projection.surface.id || this.selected?.surfaceId, - clientId: this.options.clientId, - draftStore: this.draftStore, - pluginAdapters: this.pluginAdapters, - provider: this.provider, - })); + region.append(renderEveComponent( + projection.surface.root, + scopeEveBrowserOptions(projection, this.loweringOptions()), + )); } } + + private loweringOptions(): EveBrowserLoweringOptions { + return { + activeSurfaceId: this.selected?.surfaceId, + assetUrlResolver: this.transport.resolveAssetUrl, + body: this.options.body, + clientId: this.options.clientId || "eve-browser", + commandSink: intent => this.submit(intent), + documentResolver: this.transport.resolveDocument, + provider: this.provider, + pluginAdapters: this.pluginAdapters, + source: this.options.source, + statusElement: this.options.statusElement, + draftStore: this.draftStore, + }; + } } function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void { diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index b759aba..efca89e 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; -import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js"; +import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveSurface } from "../dist/index.js"; function installDom() { const dom = new JSDOM("
", { @@ -433,3 +433,114 @@ test("authoritative refresh preserves the renderer-owned accessible command resu restoreDom(); } }); + +test("transient projection controls use the selected provider command boundary", async () => { + const restoreDom = installDom(); + try { + const host = document.querySelector("#a"); + const commands = []; + const descriptor = (command, payloadSchema) => ({ + schema: "gamecult.eve.command.v1", + command, + payloadSchema, + }); + const surfaceDocument = { + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId: "ghostlight", + providerKind: "narrative.simulation", + title: "Ghostlight", + version: 7, + updatedAtUtc: "2026-08-22T00:00:00Z", + surface: { + id: "ghostlight.play", + styles: {}, + root: { + id: "root", + kind: "control.button", + props: { label: "Assess", command: "world.assess" }, + children: [], + }, + }, + commands: [descriptor("world.assess", "ghostlight.player_action_assess.v1")], + }; + const transientProjection = { + type: "surface-state", + schema: "gamecult.eve.surface.v1", + providerId: "ghostlight", + providerKind: "narrative.simulation.command-result", + title: "Assessment", + version: 7, + updatedAtUtc: "2026-08-22T00:00:01Z", + surface: { + id: "ghostlight.command-result", + styles: {}, + root: { + id: "roll", + kind: "control.button", + props: { + label: "Roll the d20", + command: "world.attempt", + action: { assessment_digest: "sha256:assessment" }, + }, + children: [], + }, + }, + commands: [descriptor("world.attempt", "ghostlight.player_action_attempt.v1")], + }; + const receipt = command => ({ + schema: "gamecult.eve.command_receipt.v1", + receiptId: `receipt:${command}`, + commandId: `command:${command}`, + command, + state: "accepted", + ownerRepo: "GameCult/Ghostlight", + authority: "WorldKernel", + providerId: "ghostlight", + surfaceId: "ghostlight.play", + message: "accepted", + diagnostics: [], + issuedAtUtc: "2026-08-22T00:00:02Z", + sourceVersion: 7, + }); + const transport = { + providerAdvertisement: async () => ({ + providerId: "ghostlight", + surfaces: [{ + surfaceId: "ghostlight.play", + worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + }, + }], + }), + surface: async () => surfaceDocument, + submitCommand: async intent => { + commands.push(intent); + return { + schema: "gamecult.eve.command_result.v1", + receipt: receipt(intent.operation.operationId), + ...(intent.operation.operationId === "world.assess" ? { transientProjection } : {}), + }; + }, + }; + const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 }); + await providerHost.start(); + + host.querySelector("button").click(); + await new Promise(resolve => setTimeout(resolve, 0)); + const roll = host.querySelector(".eve-command-result-region button"); + assert.equal(roll.textContent, "Roll the d20"); + roll.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepEqual(commands.map(command => command.operation.operationId), ["world.assess", "world.attempt"]); + assert.equal(commands[1].surfaceId, "ghostlight.play"); + assert.equal(commands[1].operation.schemaId, "ghostlight.player_action_attempt.v1"); + assert.equal(commands[1].commandBoundary, "ghostlight.eve.commands"); + assert.equal(commands[1].payload.assessment_digest, "sha256:assessment"); + providerHost.stop(); + } finally { + restoreDom(); + } +});