From 6b5faa6a77cf59faaa46a2a96ddb3fcadab9c67f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 12:16:51 +0200 Subject: [PATCH] Preserve Eve input defaults and command receipts --- packages/eve-browser-lowering/README.md | 4 +- packages/eve-browser-lowering/dist/index.js | 37 +++++++- .../docs/host-authority.md | 8 ++ packages/eve-browser-lowering/src/index.ts | 52 +++++++++++- .../test/host-isolation.test.mjs | 85 +++++++++++++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) diff --git a/packages/eve-browser-lowering/README.md b/packages/eve-browser-lowering/README.md index f776573..df1f320 100644 --- a/packages/eve-browser-lowering/README.md +++ b/packages/eve-browser-lowering/README.md @@ -9,8 +9,8 @@ This package owns the shared browser renderer contract: - `applyEveSurfaceStyles(styles, body)` maps portable Eve style tokens to CSS custom properties. - `createEveCommandIntent(commandId, props, options)` emits the canonical `gamecult.eve.command_invocation.v1` operation descriptor, including the provider-advertised command boundary and receipt schema, a source-version route hint, and a fresh idempotency key. - `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary. -- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves draft values by provider/surface/binding identity and an operation may capture those bindings atomically under `payload.bindings`. -- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. +- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures those bindings atomically under `payload.bindings`. +- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. The renderer-owned receipt region survives the authoritative surface refresh that follows the command. Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route. diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 018dcbd..a4d7c65 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -247,7 +247,10 @@ export function renderEveSurface(surface, host, options = {}) { options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`; } if (surface.surface?.root) { + const commandResult = host.querySelector(":scope > .eve-command-result-region"); host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions)); + if (commandResult) + host.append(commandResult); if (options.stateBindingResolver) { const controller = new EveSurfaceBindingController(surface, host, scopedOptions); activeSurfaceBindings.set(host, controller); @@ -1694,7 +1697,7 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { const operationId = commandId || stringProp(action.type, "invoke"); const descriptor = surface?.commands?.find(command => command.command === operationId); const captureBindings = stringArray(descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings); - const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {}; + const bindings = captureBindingValues(surface?.surface?.root, providerId, surfaceId, captureBindings, options.draftStore); const payload = commandPayload(action); if (captureBindings.length) payload.bindings = bindings; @@ -1721,6 +1724,38 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { }; return intent; } +function captureBindingValues(root, providerId, surfaceId, bindingNames, drafts) { + const captured = {}; + for (const name of bindingNames) { + if (drafts?.has(providerId, surfaceId, name)) { + captured[name] = drafts.get(providerId, surfaceId, name); + continue; + } + const authored = findAuthoredBindingValue(root, name); + if (authored.found) + captured[name] = authored.value; + } + return captured; +} +function findAuthoredBindingValue(node, bindingName) { + if (!node) + return { found: false }; + const props = objectProps(node.props); + const binding = (node.stateBindings || []).find(candidate => firstString(candidate.bindingName, candidate.pointerId) === bindingName); + const localName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id); + if (localName === bindingName) { + const targetProp = firstString(binding?.targetProp, "value"); + if (Object.prototype.hasOwnProperty.call(props, targetProp)) { + return { found: true, value: props[targetProp] }; + } + } + for (const child of node.children || []) { + const match = findAuthoredBindingValue(child, bindingName); + if (match.found) + return match; + } + return { found: false }; +} function commandPayload(action) { const payload = { ...action }; for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) { diff --git a/packages/eve-browser-lowering/docs/host-authority.md b/packages/eve-browser-lowering/docs/host-authority.md index c733365..2a8a6bb 100644 --- a/packages/eve-browser-lowering/docs/host-authority.md +++ b/packages/eve-browser-lowering/docs/host-authority.md @@ -25,6 +25,9 @@ Derived state: - DOM nodes, focus restoration data, normalized styles, and component indexes are projections of the owning surface; +- 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; - loaded font stylesheets are a document resource cache, not active-surface state. @@ -41,6 +44,8 @@ Shared paths: creation, and asset resolution all receive the same host-scoped context; - initial binding hydration and live binding watches use the same component projection replacement primitive. +- operation capture resolves edited drafts first and otherwise uses the + authored value currently displayed by the bound control. Cut line: @@ -55,4 +60,7 @@ Verification: and control skins after either host renders or updates; - a bound leaf update does not replace either host root; - focused input selection and host scroll survive a binding update; +- untouched select and numeric defaults are present in captured operations; +- accepted and denied command results remain visible after authoritative + refresh; - 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 4ce39f0..d9eb699 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -464,7 +464,9 @@ export function renderEveSurface( options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`; } if (surface.surface?.root) { + const commandResult = host.querySelector(":scope > .eve-command-result-region"); host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions)); + if (commandResult) host.append(commandResult); if (options.stateBindingResolver) { const controller = new EveSurfaceBindingController(surface, host, scopedOptions); activeSurfaceBindings.set(host, controller); @@ -1996,7 +1998,13 @@ export function createEveCommandIntent( const captureBindings = stringArray( descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings, ); - const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {}; + const bindings = captureBindingValues( + surface?.surface?.root, + providerId, + surfaceId, + captureBindings, + options.draftStore, + ); const payload = commandPayload(action); if (captureBindings.length) payload.bindings = bindings; const intent: EveCommandIntent = { @@ -2023,6 +2031,48 @@ export function createEveCommandIntent( return intent; } +function captureBindingValues( + root: EveSurfaceComponent | undefined, + providerId: string, + surfaceId: string, + bindingNames: readonly string[], + drafts: EveBrowserDraftStore | undefined, +): Record { + const captured: Record = {}; + for (const name of bindingNames) { + if (drafts?.has(providerId, surfaceId, name)) { + captured[name] = drafts.get(providerId, surfaceId, name); + continue; + } + const authored = findAuthoredBindingValue(root, name); + if (authored.found) captured[name] = authored.value; + } + return captured; +} + +function findAuthoredBindingValue( + node: EveSurfaceComponent | undefined, + bindingName: string, +): { found: boolean; value?: unknown } { + if (!node) return { found: false }; + const props = objectProps(node.props); + const binding = (node.stateBindings || []).find(candidate => + firstString(candidate.bindingName, candidate.pointerId) === bindingName, + ); + const localName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id); + if (localName === bindingName) { + const targetProp = firstString(binding?.targetProp, "value"); + if (Object.prototype.hasOwnProperty.call(props, targetProp)) { + return { found: true, value: props[targetProp] }; + } + } + for (const child of node.children || []) { + const match = findAuthoredBindingValue(child, bindingName); + if (match.found) return match; + } + return { found: false }; +} + function commandPayload(action: Record): Record { const payload = { ...action }; for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) { diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 952efb8..d41e384 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -264,3 +264,88 @@ test("editable bindings preserve local drafts across authoritative refresh and o restoreDom(); } }); + +test("operations capture untouched authored choice defaults", () => { + const restoreDom = installDom(); + try { + const commands = []; + const host = document.querySelector("#a"); + const provider = { + providerId: "ghostlight", + surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + } }], + }; + renderEveSurface({ + providerId: "ghostlight", + version: 7, + commands: [{ + command: "session_zero.message.send", + payloadSchema: "session_zero.message.send.v1", + captureBindings: ["channel_id"], + }], + surface: { + id: "ghostlight.play", + root: { id: "root", kind: "column", children: [ + { + id: "channel", + kind: "control.select", + props: { label: "Speak at", value: "shared" }, + stateBindings: [{ + targetProp: "value", + pointerId: "ghostlight.local.channel_id", + sourceId: "eve.browser.local", + schemaId: "gamecult.eve.local_draft.v1", + routeKind: "in-process", + bindingName: "channel_id", + valueKind: "choice", + accessMode: "local-draft", + authority: "eve.browser", + }], + children: [ + { kind: "control.option", props: { label: "Shared", value: "shared" } }, + { kind: "control.option", props: { label: "Private", value: "private" } }, + ], + }, + { id: "send", kind: "control.button", props: { label: "Send", command: "session_zero.message.send" } }, + ] }, + }, + }, host, { + provider, + draftStore: new EveBrowserDraftStore(), + commandSink: intent => commands.push(intent), + }); + + assert.equal(host.querySelector("select").value, "shared"); + host.querySelector("button").click(); + assert.deepEqual(commands[0].payload.bindings, { channel_id: "shared" }); + } finally { + restoreDom(); + } +}); + +test("authoritative refresh preserves the renderer-owned command result", () => { + const restoreDom = installDom(); + try { + const host = document.querySelector("#a"); + const surface = version => ({ + providerId: "ghostlight", + version, + surface: { id: "ghostlight.play", root: { id: `root-${version}`, kind: "text", props: { value: `revision ${version}` } } }, + }); + renderEveSurface(surface(7), host); + const result = document.createElement("section"); + result.className = "eve-command-result-region"; + result.setAttribute("role", "error"); + result.textContent = "The command was denied."; + host.append(result); + + renderEveSurface(surface(8), host); + assert.equal(host.querySelector(".eve-command-result-region"), result); + assert.equal(result.textContent, "The command was denied."); + assert.match(host.textContent, /revision 8/); + } finally { + restoreDom(); + } +});