Preserve Eve input defaults and command receipts

This commit is contained in:
Meta Vens 2026-08-22 12:16:51 +02:00
commit 6b5faa6a77
5 changed files with 182 additions and 4 deletions

View file

@ -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.

View file

@ -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"]) {

View file

@ -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.

View file

@ -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<HTMLElement>(":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<string, unknown> {
const captured: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
const payload = { ...action };
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {

View file

@ -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();
}
});