Repair Eve browser command and editable binding contracts

This commit is contained in:
Meta Vens 2026-08-22 00:02:17 +02:00
commit 4338a42c6e
32 changed files with 1533 additions and 86 deletions

View file

@ -58,6 +58,13 @@ Providers own:
- reconciliation;
- side effects.
Command delivery returns `gamecult.eve.command_result.v1`. The generic result
always contains the provider's persisted receipt and may carry a transient Eve
projection. A single optional plugin payload names its plugin id and schema;
only that plugin's renderer adapter may consume it. The payload cannot accept a
provider command or mutate provider state, so plugin rendering never becomes a
shadow authority.
Odin/CultMesh owns:
- provider advertisement discovery;

View file

@ -247,14 +247,20 @@ invocations use `gamecult.eve.command_invocation.v1`:
```json
{
"type": "surface-command",
"schema": "gamecult.eve.command_invocation.v1",
"providerId": "gamecult.home.vn",
"surfaceId": "sai.visual_novel.surface",
"command": "story.choose",
"operation": {
"operationId": "story.choose",
"schemaId": "sai.story_choice.v1",
"idempotencyKey": "21a816d5-7ed5-4513-afb0-0a9bc9aa334b",
"routeHint": { "sourceVersion": 41 }
},
"payload": { "index": 0, "targetPath": "eve" },
"issuedAt": "2026-05-31T00:00:00.000Z",
"clientId": "browser.reference"
"clientId": "browser.reference",
"commandBoundary": "sai.commands",
"receiptSchema": "gamecult.eve.command_receipt.v1"
}
```
@ -283,6 +289,13 @@ are not the authoritative live command model and should not appear as public
runtime construction APIs.
Renderer command requests carry `CultMeshOperationInvocationDescriptor` values.
Editable controls remain state bindings rather than form-shaped command DTOs.
Bindings may additionally name their document and field, value kind, access
mode, authority, stable `bindingName`, and optional `writeCommand`. Renderers
keep unaccepted edits as local drafts. Operations declare `captureBindings` and
receive those values under `payload.bindings`; accepted command results may
request that exact bindings be cleared. HTML form elements are an accessibility
lowering detail, not part of Eve's public ontology.
That invocation descriptor is the canonical operation identity at click/change
time and preserves request schema, preferred route, and optional idempotency.
Renderer command requests also carry `CultMeshOperationPayload`, a shared

View file

@ -7,9 +7,13 @@ This package owns the shared browser renderer contract:
- `renderEveSurface(surface, host, options)` lowers an Eve surface document into a DOM host.
- `renderEveComponent(component, options)` lowers a single component tree.
- `applyEveSurfaceStyles(styles, body)` maps portable Eve style tokens to CSS custom properties.
- `createEveCommandIntent(commandId, props, options)` creates the browser command envelope for host daemons and carries the provider-advertised `commandBoundary` / `receiptSchema` when the active surface exposes `worldInteraction`.
- `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.
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.
An HTML `<form>` may be used internally for keyboard behavior, but it is not an Eve primitive or state owner. Eve exposes editable bindings and typed operations; HTML remains a lowering detail.
Asset references are left as-is unless the host provides `assetUrlResolver`. Eir uses that hook only for CultMesh/CultCache asset URIs such as `cultmesh://schema/record` or `cultcache://schema/record`; ordinary HTTPS assets and web fonts remain ordinary browser URLs.

View file

@ -4,6 +4,14 @@ export interface EveBrowserPluginAdapter {
componentKinds: readonly string[];
schemas: readonly string[];
normalizeDocument(schemaId: string | undefined, value: unknown): unknown;
consumeCommandResult?: (payload: {
pluginId: string;
schemaId: string;
payload: Record<string, unknown>;
}, result: unknown, context: {
provider: unknown;
surface: unknown;
}) => void | Promise<void>;
renderComponent?: (component: FieldsComponent, props: Record<string, unknown>, layout: Record<string, unknown>, style: Record<string, unknown>, options: FieldsLoweringOptions, context: FieldsRuntimeContext) => HTMLElement;
}
export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter;

View file

@ -1,4 +1,5 @@
import { type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
import { type EveCommandInvocation, type EveCommandResult } from "@gamecult/eve-contracts";
export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
export interface EveSurfaceComponent {
id?: string;
@ -19,6 +20,13 @@ export interface EveStateBindingDescriptor {
schemaId: string;
routeKind: string;
routeDescription?: string;
bindingName?: string;
documentId?: string;
fieldPath?: string;
valueKind?: "string" | "number" | "boolean" | "choice" | "string-list";
accessMode?: "read" | "write" | "read-write" | "local-draft";
authority?: string;
writeCommand?: string;
}
export interface EveStateBindingHandle {
latest(): Promise<unknown>;
@ -36,6 +44,12 @@ export interface EveSurfaceDocument {
styles?: EveSurfaceStyles;
};
mesh?: unknown;
commands?: Array<{
command: string;
payloadSchema?: string;
captureBindings?: string[];
transport?: string;
}>;
}
export type EveSurfaceStyles = {
tokens?: Record<string, unknown>;
@ -49,18 +63,8 @@ export type EveSurfaceStyles = {
name?: string;
value?: unknown;
}>;
export interface EveCommandIntent {
type: "surface-command";
schema: "gamecult.eve.command_invocation.v1";
providerId: string;
surfaceId: string;
command: string;
commandBoundary?: string;
receiptSchema?: string;
payload: Record<string, unknown>;
issuedAt: string;
clientId: string;
}
export type EveCommandIntent = EveCommandInvocation;
export type EveBrowserCommandResult = EveCommandResult;
export interface EveSemanticListItem {
label: string;
status: string;
@ -99,13 +103,14 @@ export interface EveBrowserLoweringOptions {
assetUrlResolver?: (uri: string, surface: EveSurfaceDocument | undefined) => string;
body?: HTMLElement;
clientId?: string;
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise<void>;
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise<EveBrowserCommandResult | void>;
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
provider?: EveProviderAdvertisement;
pluginAdapters?: readonly EveBrowserPluginAdapter[];
stateBindingResolver?: EveStateBindingResolver;
source?: string;
statusElement?: HTMLElement;
draftStore?: EveBrowserDraftStore;
}
export interface EveBrowserProviderTransport {
providerAdvertisement(): Promise<EveProviderAdvertisement>;
@ -123,6 +128,15 @@ export interface EveBrowserProviderHostOptions {
statusElement?: HTMLElement;
pluginAdapters?: readonly EveBrowserPluginAdapter[];
}
export declare class EveBrowserDraftStore {
private readonly values;
private key;
has(providerId: string, surfaceId: string, bindingName: string): boolean;
get(providerId: string, surfaceId: string, bindingName: string): unknown;
set(providerId: string, surfaceId: string, bindingName: string, value: unknown): void;
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void;
capture(providerId: string, surfaceId: string, bindingNames: readonly string[]): Record<string, unknown>;
}
export declare const defaultBrowserPluginAdapters: readonly EveBrowserPluginAdapter[];
export declare function resolveRequiredPluginAdapters(surface: EveProviderSurfaceAdvertisement, available?: readonly EveBrowserPluginAdapter[]): readonly EveBrowserPluginAdapter[];
export declare function selectAdvertisedSurface(provider: EveProviderAdvertisement, requestedSurfaceId?: string): EveProviderSurfaceAdvertisement;
@ -136,11 +150,14 @@ export declare class EveBrowserProviderHost {
private provider;
private selected;
private pluginAdapters;
private readonly draftStore;
constructor(host: HTMLElement, transport: EveBrowserProviderTransport, options?: EveBrowserProviderHostOptions);
start(): Promise<void>;
stop(): void;
refresh(): Promise<void>;
private submit;
private consumeCommandResult;
private presentCommandStatus;
}
export interface EveEmbeddedDocumentRequest {
documentId: string;

View file

@ -1,5 +1,35 @@
import { fieldsBrowserAdapter } from "./fields-browser-adapter.js";
import { parseEveCommandResult, } from "@gamecult/eve-contracts";
export { fieldsBrowserAdapter, normalizeFieldsDocument } from "./fields-browser-adapter.js";
export class EveBrowserDraftStore {
values = new Map();
key(providerId, surfaceId, bindingName) {
return `${providerId}\u001f${surfaceId}\u001f${bindingName}`;
}
has(providerId, surfaceId, bindingName) {
return this.values.has(this.key(providerId, surfaceId, bindingName));
}
get(providerId, surfaceId, bindingName) {
return this.values.get(this.key(providerId, surfaceId, bindingName));
}
set(providerId, surfaceId, bindingName, value) {
this.values.set(this.key(providerId, surfaceId, bindingName), value);
}
clear(providerId, surfaceId, bindingNames) {
if (bindingNames?.length) {
for (const name of bindingNames)
this.values.delete(this.key(providerId, surfaceId, name));
return;
}
const prefix = `${providerId}\u001f${surfaceId}\u001f`;
for (const key of this.values.keys())
if (key.startsWith(prefix))
this.values.delete(key);
}
capture(providerId, surfaceId, bindingNames) {
return Object.fromEntries(bindingNames.map(name => [name, this.get(providerId, surfaceId, name)]));
}
}
export const defaultBrowserPluginAdapters = [fieldsBrowserAdapter];
export function resolveRequiredPluginAdapters(surface, available = defaultBrowserPluginAdapters) {
const resolved = [];
@ -39,15 +69,26 @@ export class EveBrowserProviderHost {
provider;
selected;
pluginAdapters = [];
draftStore = new EveBrowserDraftStore();
constructor(host, transport, options = {}) {
this.host = host;
this.transport = transport;
this.options = options;
}
async start() {
this.provider = await this.transport.providerAdvertisement();
this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId);
this.pluginAdapters = resolveRequiredPluginAdapters(this.selected, this.options.pluginAdapters || defaultBrowserPluginAdapters);
try {
this.provider = await this.transport.providerAdvertisement();
this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId);
requireCommandCapability(this.selected);
this.pluginAdapters = resolveRequiredPluginAdapters(this.selected, this.options.pluginAdapters || defaultBrowserPluginAdapters);
}
catch (error) {
const message = error instanceof Error ? error.message : "Eve provider capabilities are unavailable.";
this.host.replaceChildren(emptyState(message));
if (this.options.statusElement)
this.options.statusElement.textContent = message;
throw error;
}
this.active = true;
await this.refresh();
const pollMs = Math.max(0, this.options.pollMs ?? 250);
@ -81,6 +122,7 @@ export class EveBrowserProviderHost {
pluginAdapters: this.pluginAdapters,
source: this.options.source,
statusElement: this.options.statusElement,
draftStore: this.draftStore,
});
}
catch (error) {
@ -90,10 +132,59 @@ export class EveBrowserProviderHost {
}
}
async submit(intent) {
await this.transport.submitCommand(intent);
try {
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
await this.consumeCommandResult(intent, result);
}
catch (error) {
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
return;
}
this.lastSurfaceVersion = "";
window.setTimeout(() => void this.refresh(), 100);
}
async consumeCommandResult(intent, result) {
const accepted = result.receipt.state === "accepted" || result.receipt.state === "reconciled";
if (accepted && result.draftDirective?.clear) {
this.draftStore.clear(intent.providerId, intent.surfaceId, result.draftDirective.bindingNames);
}
if (result.pluginPayload) {
const adapter = this.pluginAdapters.find(candidate => candidate.pluginId === result.pluginPayload?.pluginId);
if (!adapter?.consumeCommandResult) {
throw new Error(`No Eve plugin adapter can consume ${result.pluginPayload.pluginId}.`);
}
await adapter.consumeCommandResult(result.pluginPayload, result, {
provider: this.provider,
surface: this.selected,
});
}
this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "error", result.transientProjection);
}
presentCommandStatus(message, role, transient) {
let region = this.host.querySelector(":scope > .eve-command-result-region");
if (!region) {
region = el("section", "eve-command-result-region");
region.setAttribute("aria-live", "polite");
this.host.append(region);
}
region.setAttribute("role", role);
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,
}));
}
}
}
function requireCommandCapability(surface) {
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
throw new Error(`Eve surface ${surface.surfaceId || "(unknown)"} does not advertise a command boundary and receipt schema.`);
}
}
const defaultStyleTokens = {
"--bg": "#020909",
@ -124,6 +215,7 @@ const defaultStyleTokens = {
};
const loadedFontStylesheets = new Map();
const activeSurfaceBindings = new WeakMap();
const surfaceDraftStores = new WeakMap();
const eveBrowserRenderContext = Symbol("eve.browser.render-context");
function scopeEveBrowserOptions(surface, options) {
return {
@ -140,7 +232,12 @@ function renderContext(options) {
}
export function renderEveSurface(surface, host, options = {}) {
activeSurfaceBindings.get(host)?.dispose();
const scopedOptions = scopeEveBrowserOptions(surface, options);
let draftStore = options.draftStore || surfaceDraftStores.get(host);
if (!draftStore) {
draftStore = new EveBrowserDraftStore();
surfaceDraftStores.set(host, draftStore);
}
const scopedOptions = scopeEveBrowserOptions(surface, { ...options, draftStore });
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host);
host.dataset.provider = surface.providerId || options.provider?.providerId || "";
if (options.body) {
@ -686,14 +783,15 @@ function renderEveComponentProjection(node, options) {
if (kind === "input.select" || kind === "control.select") {
return renderSelect(node, props, children, layout, style, options);
}
if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") {
const field = el("label", "field cultui-field");
assignId(field, node);
applyBoxProps(field, props);
applyGeneratedLayout(field, layout, style);
field.append(el("span", "field-label", stringProp(props.label, "")));
field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value)));
return field;
if (kind === "control.choice-group") {
return renderChoiceGroup(node, props, children, layout, style, options);
}
if (kind === "input.number" || kind === "control.range" || kind === "control.input.number" ||
kind === "control.input.text" || kind === "control.input.textarea") {
return renderEditableControl(node, kind, props, layout, style, options);
}
if (kind === "control.tabs") {
return renderTabs(node, props, children, layout, style, options);
}
if (kind === "color.swatch") {
const swatch = el("div", "paint-swatch cultui-swatch");
@ -1590,25 +1688,57 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) {
const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId);
const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target);
const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema);
if (!commandBoundary || !receiptSchema) {
throw new Error(`Eve surface ${surfaceId} cannot invoke commands without an advertised command boundary and receipt schema.`);
}
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 payload = commandPayload(action);
if (captureBindings.length)
payload.bindings = bindings;
const intent = {
type: "surface-command",
schema: "gamecult.eve.command_invocation.v1",
providerId,
surfaceId,
command: commandId || stringProp(action.type, "invoke"),
payload: {
...action,
transport: props.transport ?? null,
operation: {
operationId,
schemaId: firstString(descriptor?.payloadSchema, action.schemaId, props.payloadSchema, "gamecult.eve.operation_payload.v1"),
idempotencyKey: randomIdempotencyKey(),
routeHint: {
sourceVersion: Math.max(0, Math.trunc(surface?.version ?? 0)),
...(firstString(descriptor?.transport, props.transport, action.transport)
? { transport: firstString(descriptor?.transport, props.transport, action.transport) }
: {}),
},
},
payload,
issuedAt: new Date().toISOString(),
clientId: options.clientId || "eve.browser",
commandBoundary,
receiptSchema,
};
if (commandBoundary)
intent.commandBoundary = commandBoundary;
if (receiptSchema)
intent.receiptSchema = receiptSchema;
return intent;
}
function commandPayload(action) {
const payload = { ...action };
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {
delete payload[key];
}
return payload;
}
function randomIdempotencyKey() {
if (typeof globalThis.crypto?.randomUUID === "function")
return globalThis.crypto.randomUUID();
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues?.(bytes);
if (!bytes.some(Boolean)) {
for (let index = 0; index < bytes.length; index++)
bytes[index] = Math.floor(Math.random() * 256);
}
return [...bytes].map(value => value.toString(16).padStart(2, "0")).join("");
}
function resolveAdvertisedWorldInteraction(options, surfaceId) {
const surfaces = [
...(options.provider?.surfaces || []),
@ -1643,7 +1773,172 @@ function wireCommand(element, node, commandId, props, options) {
}
});
}
function editableBindingContext(node, props, options) {
const binding = (node.stateBindings || []).find(candidate => candidate.targetProp === "value")
?? node.stateBindings?.[0];
const surface = renderContext(options)?.surface;
const providerId = options.provider?.providerId || surface?.providerId || "surface unknown";
const surfaceId = options.activeSurfaceId || surface?.surface?.id || providerId;
const bindingName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id);
const draft = bindingName && options.draftStore?.has(providerId, surfaceId, bindingName)
? options.draftStore.get(providerId, surfaceId, bindingName)
: undefined;
const accessMode = binding?.accessMode || (binding ? "read" : "local-draft");
return {
binding,
bindingName,
providerId,
surfaceId,
value: draft ?? props.value ?? "",
disabled: boolProp(props.disabled) || accessMode === "read",
};
}
function updateDraft(context, value, options) {
if (context.bindingName)
options.draftStore?.set(context.providerId, context.surfaceId, context.bindingName, value);
}
function publishDirectEdit(context, value, node, options) {
const binding = context.binding;
const writeCommand = binding?.writeCommand;
if (!binding || !writeCommand || !context.bindingName)
return;
void options.commandSink?.(createEveCommandIntent(writeCommand, {
action: {
bindingName: context.bindingName,
documentId: binding.documentId,
fieldPath: binding.fieldPath,
value,
},
}, options), node);
}
function renderEditableControl(node, kind, props, layout, style, options) {
const context = editableBindingContext(node, props, options);
const field = el("label", "field cultui-field cultui-editable");
assignId(field, node);
applyBoxProps(field, props);
applyGeneratedLayout(field, layout, style);
const label = stringProp(props.label, "");
if (label)
field.append(el("span", "field-label", label));
const control = kind === "control.input.textarea"
? el("textarea", "field-control cultui-field-input")
: el("input", "field-control cultui-field-input");
if (control instanceof HTMLInputElement) {
control.type = kind.includes("number") || kind === "control.range" ? "number" : "text";
if (props.min !== undefined)
control.min = String(props.min);
if (props.max !== undefined)
control.max = String(props.max);
if (props.step !== undefined)
control.step = String(props.step);
}
else if (props.rows !== undefined) {
control.rows = positiveInt(props.rows, 3);
}
control.value = String(context.value ?? "");
control.disabled = context.disabled;
control.name = context.bindingName;
control.dataset.bindingName = context.bindingName;
control.setAttribute("aria-label", label || context.bindingName || node.id || "Input");
if (props.placeholder !== undefined)
control.setAttribute("placeholder", String(props.placeholder));
if (boolProp(props.required))
control.required = true;
control.addEventListener("input", () => {
const value = control instanceof HTMLInputElement && control.type === "number"
? (control.value === "" ? "" : Number(control.value))
: control.value;
updateDraft(context, value, options);
});
control.addEventListener("change", () => {
const value = control instanceof HTMLInputElement && control.type === "number"
? (control.value === "" ? "" : Number(control.value))
: control.value;
publishDirectEdit(context, value, node, options);
});
field.append(control);
const validation = firstString(props.validationMessage, props.error, "");
if (validation) {
const message = el("span", "cultui-field-validation", validation);
message.setAttribute("role", "alert");
field.append(message);
control.setAttribute("aria-invalid", "true");
}
return field;
}
function renderChoiceGroup(node, props, children, layout, style, options) {
const context = editableBindingContext(node, props, options);
const group = el("fieldset", "field cultui-field cultui-choice-group");
assignId(group, node);
applyGeneratedLayout(group, layout, style);
const label = firstString(props.label, node.text, context.bindingName);
if (label)
group.append(el("legend", "field-label", label));
for (const child of children) {
const optionProps = objectProps(child.props);
const value = firstString(optionProps.value, child.id);
const option = el("label", "cultui-choice");
const input = el("input");
input.type = boolProp(props.multiple) ? "checkbox" : "radio";
input.name = context.bindingName;
input.value = value;
input.disabled = context.disabled || boolProp(optionProps.disabled);
input.checked = input.type === "checkbox"
? stringArray(context.value).includes(value)
: String(context.value) === value;
input.addEventListener("change", () => {
const next = input.type === "checkbox"
? [...group.querySelectorAll("input:checked")].map(candidate => candidate.value)
: value;
updateDraft(context, next, options);
publishDirectEdit(context, next, node, options);
});
option.append(input, document.createTextNode(firstString(optionProps.label, child.text, value)));
group.append(option);
}
return group;
}
function renderTabs(node, props, children, layout, style, options) {
const context = editableBindingContext(node, { ...props, bind: props.bind || `${node.id || "tabs"}.selected` }, options);
const root = el("section", "cultui-tabs");
assignId(root, node);
applyGeneratedLayout(root, layout, style);
const tabList = el("div", "cultui-tab-list");
tabList.setAttribute("role", "tablist");
const panels = el("div", "cultui-tab-panels");
const selected = firstString(context.value, objectProps(children[0]?.props).value, children[0]?.id);
const activate = (value) => {
updateDraft(context, value, options);
for (const button of tabList.querySelectorAll("[role=tab]")) {
const active = button.dataset.value === value;
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
}
for (const panel of panels.querySelectorAll("[role=tabpanel]"))
panel.hidden = panel.dataset.value !== value;
};
for (const [index, child] of children.entries()) {
const childProps = objectProps(child.props);
const value = firstString(childProps.value, child.id, String(index));
const button = el("button", "cultui-tab", firstString(childProps.label, child.text, value));
button.type = "button";
button.dataset.value = value;
button.setAttribute("role", "tab");
button.addEventListener("click", () => activate(value));
tabList.append(button);
const panel = el("section", "cultui-tab-panel");
panel.dataset.value = value;
panel.setAttribute("role", "tabpanel");
for (const content of child.children || [])
panel.append(renderEveComponent(content, options));
panels.append(panel);
}
root.append(tabList, panels);
activate(selected);
return root;
}
function renderSelect(node, props, children, layout, style, options) {
const context = editableBindingContext(node, props, options);
const field = el("label", "field cultui-field cultui-select");
assignId(field, node);
applyBoxProps(field, props);
@ -1664,12 +1959,15 @@ function renderSelect(node, props, children, layout, style, options) {
(optionProps.enabled !== undefined && !boolProp(optionProps.enabled));
select.append(option);
}
select.value = stringProp(props.value, "");
select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled));
select.value = String(context.value ?? "");
select.name = context.bindingName;
select.dataset.bindingName = context.bindingName;
select.disabled = context.disabled || (props.enabled !== undefined && !boolProp(props.enabled));
const commandId = resolveComponentCommandId(props, node);
if (commandId) {
select.dataset.commandId = commandId;
select.addEventListener("change", () => {
updateDraft(context, select.value, options);
const intent = createEveCommandIntent(commandId, {
...props,
action: {
@ -1680,6 +1978,12 @@ function renderSelect(node, props, children, layout, style, options) {
void options.commandSink?.(intent, node);
});
}
else {
select.addEventListener("change", () => {
updateDraft(context, select.value, options);
publishDirectEdit(context, select.value, node, options);
});
}
field.append(select);
return field;
}
@ -1917,6 +2221,13 @@ export function projectSemanticListItems(value) {
function stringProp(value, fallback) {
return value === null || value === undefined ? fallback : String(value);
}
function stringArray(value) {
if (Array.isArray(value))
return value.map(item => String(item).trim()).filter(Boolean);
if (typeof value === "string")
return value.split(",").map(item => item.trim()).filter(Boolean);
return [];
}
function firstString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim())

View file

@ -7,12 +7,29 @@
"": {
"name": "@gamecult/eve-browser-lowering",
"version": "0.1.1",
"dependencies": {
"@gamecult/eve-contracts": "file:../eve-contracts"
},
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",
"typescript": "^5.6.3"
}
},
"../eve-contracts": {
"name": "@gamecult/eve-contracts",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1"
},
"devDependencies": {
"@msgpack/msgpack": "^3.1.2",
"@types/node": "^24.0.0",
"json-schema-to-typescript": "^15.0.4",
"typescript": "^5.9.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
@ -217,6 +234,10 @@
}
}
},
"node_modules/@gamecult/eve-contracts": {
"resolved": "../eve-contracts",
"link": true
},
"node_modules/@types/jsdom": {
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz",

View file

@ -8,6 +8,9 @@
"dist",
"styles.css"
],
"dependencies": {
"@gamecult/eve-contracts": "file:../eve-contracts"
},
"scripts": {
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
"test": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test test/*.test.mjs"

View file

@ -12,6 +12,11 @@ export interface EveBrowserPluginAdapter {
componentKinds: readonly string[];
schemas: readonly string[];
normalizeDocument(schemaId: string | undefined, value: unknown): unknown;
consumeCommandResult?: (
payload: { pluginId: string; schemaId: string; payload: Record<string, unknown> },
result: unknown,
context: { provider: unknown; surface: unknown },
) => void | Promise<void>;
renderComponent?: (
component: FieldsComponent,
props: Record<string, unknown>,

View file

@ -1,4 +1,9 @@
import { fieldsBrowserAdapter, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
import {
parseEveCommandResult,
type EveCommandInvocation,
type EveCommandResult,
} from "@gamecult/eve-contracts";
export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
@ -22,6 +27,13 @@ export interface EveStateBindingDescriptor {
schemaId: string;
routeKind: string;
routeDescription?: string;
bindingName?: string;
documentId?: string;
fieldPath?: string;
valueKind?: "string" | "number" | "boolean" | "choice" | "string-list";
accessMode?: "read" | "write" | "read-write" | "local-draft";
authority?: string;
writeCommand?: string;
}
export interface EveStateBindingHandle {
@ -45,6 +57,12 @@ export interface EveSurfaceDocument {
styles?: EveSurfaceStyles;
};
mesh?: unknown;
commands?: Array<{
command: string;
payloadSchema?: string;
captureBindings?: string[];
transport?: string;
}>;
}
export type EveSurfaceStyles =
@ -55,18 +73,8 @@ export type EveSurfaceStyles =
}
| Array<{ name?: string; value?: unknown }>;
export interface EveCommandIntent {
type: "surface-command";
schema: "gamecult.eve.command_invocation.v1";
providerId: string;
surfaceId: string;
command: string;
commandBoundary?: string;
receiptSchema?: string;
payload: Record<string, unknown>;
issuedAt: string;
clientId: string;
}
export type EveCommandIntent = EveCommandInvocation;
export type EveBrowserCommandResult = EveCommandResult;
export interface EveSemanticListItem {
label: string;
@ -111,13 +119,14 @@ export interface EveBrowserLoweringOptions {
assetUrlResolver?: (uri: string, surface: EveSurfaceDocument | undefined) => string;
body?: HTMLElement;
clientId?: string;
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise<void>;
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise<EveBrowserCommandResult | void>;
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
provider?: EveProviderAdvertisement;
pluginAdapters?: readonly EveBrowserPluginAdapter[];
stateBindingResolver?: EveStateBindingResolver;
source?: string;
statusElement?: HTMLElement;
draftStore?: EveBrowserDraftStore;
}
export interface EveBrowserProviderTransport {
@ -138,6 +147,39 @@ export interface EveBrowserProviderHostOptions {
pluginAdapters?: readonly EveBrowserPluginAdapter[];
}
export class EveBrowserDraftStore {
private readonly values = new Map<string, unknown>();
private key(providerId: string, surfaceId: string, bindingName: string): string {
return `${providerId}\u001f${surfaceId}\u001f${bindingName}`;
}
has(providerId: string, surfaceId: string, bindingName: string): boolean {
return this.values.has(this.key(providerId, surfaceId, bindingName));
}
get(providerId: string, surfaceId: string, bindingName: string): unknown {
return this.values.get(this.key(providerId, surfaceId, bindingName));
}
set(providerId: string, surfaceId: string, bindingName: string, value: unknown): void {
this.values.set(this.key(providerId, surfaceId, bindingName), value);
}
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void {
if (bindingNames?.length) {
for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name));
return;
}
const prefix = `${providerId}\u001f${surfaceId}\u001f`;
for (const key of this.values.keys()) if (key.startsWith(prefix)) this.values.delete(key);
}
capture(providerId: string, surfaceId: string, bindingNames: readonly string[]): Record<string, unknown> {
return Object.fromEntries(bindingNames.map(name => [name, this.get(providerId, surfaceId, name)]));
}
}
export const defaultBrowserPluginAdapters: readonly EveBrowserPluginAdapter[] = [fieldsBrowserAdapter];
export function resolveRequiredPluginAdapters(
@ -183,6 +225,7 @@ export class EveBrowserProviderHost {
private provider: EveProviderAdvertisement | undefined;
private selected: EveProviderSurfaceAdvertisement | undefined;
private pluginAdapters: readonly EveBrowserPluginAdapter[] = [];
private readonly draftStore = new EveBrowserDraftStore();
constructor(
private readonly host: HTMLElement,
@ -191,12 +234,20 @@ export class EveBrowserProviderHost {
) {}
async start(): Promise<void> {
this.provider = await this.transport.providerAdvertisement();
this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId);
this.pluginAdapters = resolveRequiredPluginAdapters(
this.selected,
this.options.pluginAdapters || defaultBrowserPluginAdapters,
);
try {
this.provider = await this.transport.providerAdvertisement();
this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId);
requireCommandCapability(this.selected);
this.pluginAdapters = resolveRequiredPluginAdapters(
this.selected,
this.options.pluginAdapters || defaultBrowserPluginAdapters,
);
} catch (error) {
const message = error instanceof Error ? error.message : "Eve provider capabilities are unavailable.";
this.host.replaceChildren(emptyState(message));
if (this.options.statusElement) this.options.statusElement.textContent = message;
throw error;
}
this.active = true;
await this.refresh();
const pollMs = Math.max(0, this.options.pollMs ?? 250);
@ -229,6 +280,7 @@ export class EveBrowserProviderHost {
pluginAdapters: this.pluginAdapters,
source: this.options.source,
statusElement: this.options.statusElement,
draftStore: this.draftStore,
});
} catch (error) {
if (this.options.statusElement) {
@ -238,10 +290,65 @@ export class EveBrowserProviderHost {
}
private async submit(intent: EveCommandIntent): Promise<void> {
await this.transport.submitCommand(intent);
try {
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
await this.consumeCommandResult(intent, result);
} catch (error) {
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
return;
}
this.lastSurfaceVersion = "";
window.setTimeout(() => void this.refresh(), 100);
}
private async consumeCommandResult(intent: EveCommandIntent, result: EveBrowserCommandResult): Promise<void> {
const accepted = result.receipt.state === "accepted" || result.receipt.state === "reconciled";
if (accepted && result.draftDirective?.clear) {
this.draftStore.clear(intent.providerId, intent.surfaceId, result.draftDirective.bindingNames);
}
if (result.pluginPayload) {
const adapter = this.pluginAdapters.find(candidate => candidate.pluginId === result.pluginPayload?.pluginId);
if (!adapter?.consumeCommandResult) {
throw new Error(`No Eve plugin adapter can consume ${result.pluginPayload.pluginId}.`);
}
await adapter.consumeCommandResult(result.pluginPayload, result, {
provider: this.provider,
surface: this.selected,
});
}
this.presentCommandStatus(
result.receipt.message || result.receipt.state,
accepted ? "status" : "error",
result.transientProjection,
);
}
private presentCommandStatus(message: string, role: "status" | "error", transient?: unknown): void {
let region = this.host.querySelector<HTMLElement>(":scope > .eve-command-result-region");
if (!region) {
region = el("section", "eve-command-result-region");
region.setAttribute("aria-live", "polite");
this.host.append(region);
}
region.setAttribute("role", role);
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,
}));
}
}
}
function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void {
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
throw new Error(`Eve surface ${surface.surfaceId || "(unknown)"} does not advertise a command boundary and receipt schema.`);
}
}
export interface EveEmbeddedDocumentRequest {
@ -299,6 +406,7 @@ const defaultStyleTokens: Record<string, string> = {
const loadedFontStylesheets = new Map<string, HTMLLinkElement>();
const activeSurfaceBindings = new WeakMap<HTMLElement, EveSurfaceBindingController>();
const surfaceDraftStores = new WeakMap<HTMLElement, EveBrowserDraftStore>();
interface NormalizedStyles {
tokens: Record<string, unknown>;
@ -341,7 +449,12 @@ export function renderEveSurface(
options: EveBrowserLoweringOptions = {},
): HTMLElement {
activeSurfaceBindings.get(host)?.dispose();
const scopedOptions = scopeEveBrowserOptions(surface, options);
let draftStore = options.draftStore || surfaceDraftStores.get(host);
if (!draftStore) {
draftStore = new EveBrowserDraftStore();
surfaceDraftStores.set(host, draftStore);
}
const scopedOptions = scopeEveBrowserOptions(surface, { ...options, draftStore });
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host);
host.dataset.provider = surface.providerId || options.provider?.providerId || "";
if (options.body) {
@ -889,14 +1002,19 @@ function renderEveComponentProjection(
return renderSelect(node, props, children, layout, style, options);
}
if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") {
const field = el("label", "field cultui-field");
assignId(field, node);
applyBoxProps(field, props);
applyGeneratedLayout(field, layout, style);
field.append(el("span", "field-label", stringProp(props.label, "")));
field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value)));
return field;
if (kind === "control.choice-group") {
return renderChoiceGroup(node, props, children, layout, style, options);
}
if (
kind === "input.number" || kind === "control.range" || kind === "control.input.number" ||
kind === "control.input.text" || kind === "control.input.textarea"
) {
return renderEditableControl(node, kind, props, layout, style, options);
}
if (kind === "control.tabs") {
return renderTabs(node, props, children, layout, style, options);
}
if (kind === "color.swatch") {
@ -1870,24 +1988,59 @@ export function createEveCommandIntent(
const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId);
const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target);
const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema);
if (!commandBoundary || !receiptSchema) {
throw new Error(`Eve surface ${surfaceId} cannot invoke commands without an advertised command boundary and receipt schema.`);
}
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 payload = commandPayload(action);
if (captureBindings.length) payload.bindings = bindings;
const intent: EveCommandIntent = {
type: "surface-command",
schema: "gamecult.eve.command_invocation.v1",
providerId,
surfaceId,
command: commandId || stringProp(action.type, "invoke"),
payload: {
...action,
transport: props.transport ?? null,
operation: {
operationId,
schemaId: firstString(descriptor?.payloadSchema, action.schemaId, props.payloadSchema, "gamecult.eve.operation_payload.v1"),
idempotencyKey: randomIdempotencyKey(),
routeHint: {
sourceVersion: Math.max(0, Math.trunc(surface?.version ?? 0)),
...(firstString(descriptor?.transport, props.transport, action.transport)
? { transport: firstString(descriptor?.transport, props.transport, action.transport) }
: {}),
},
},
payload,
issuedAt: new Date().toISOString(),
clientId: options.clientId || "eve.browser",
commandBoundary,
receiptSchema,
};
if (commandBoundary) intent.commandBoundary = commandBoundary;
if (receiptSchema) intent.receiptSchema = receiptSchema;
return intent;
}
function commandPayload(action: Record<string, unknown>): Record<string, unknown> {
const payload = { ...action };
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {
delete payload[key];
}
return payload;
}
function randomIdempotencyKey(): string {
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues?.(bytes);
if (!bytes.some(Boolean)) {
for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256);
}
return [...bytes].map(value => value.toString(16).padStart(2, "0")).join("");
}
function resolveAdvertisedWorldInteraction(
options: EveBrowserLoweringOptions,
surfaceId: string,
@ -1944,6 +2097,207 @@ function wireCommand(
});
}
interface EditableBindingContext {
bindingName: string;
providerId: string;
surfaceId: string;
value: unknown;
disabled: boolean;
binding?: EveStateBindingDescriptor;
}
function editableBindingContext(
node: EveSurfaceComponent,
props: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): EditableBindingContext {
const binding = (node.stateBindings || []).find(candidate => candidate.targetProp === "value")
?? node.stateBindings?.[0];
const surface = renderContext(options)?.surface;
const providerId = options.provider?.providerId || surface?.providerId || "surface unknown";
const surfaceId = options.activeSurfaceId || surface?.surface?.id || providerId;
const bindingName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id);
const draft = bindingName && options.draftStore?.has(providerId, surfaceId, bindingName)
? options.draftStore.get(providerId, surfaceId, bindingName)
: undefined;
const accessMode = binding?.accessMode || (binding ? "read" : "local-draft");
return {
binding,
bindingName,
providerId,
surfaceId,
value: draft ?? props.value ?? "",
disabled: boolProp(props.disabled) || accessMode === "read",
};
}
function updateDraft(
context: EditableBindingContext,
value: unknown,
options: EveBrowserLoweringOptions,
): void {
if (context.bindingName) options.draftStore?.set(context.providerId, context.surfaceId, context.bindingName, value);
}
function publishDirectEdit(
context: EditableBindingContext,
value: unknown,
node: EveSurfaceComponent,
options: EveBrowserLoweringOptions,
): void {
const binding = context.binding;
const writeCommand = binding?.writeCommand;
if (!binding || !writeCommand || !context.bindingName) return;
void options.commandSink?.(createEveCommandIntent(writeCommand, {
action: {
bindingName: context.bindingName,
documentId: binding.documentId,
fieldPath: binding.fieldPath,
value,
},
}, options), node);
}
function renderEditableControl(
node: EveSurfaceComponent,
kind: string,
props: Record<string, unknown>,
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): HTMLElement {
const context = editableBindingContext(node, props, options);
const field = el("label", "field cultui-field cultui-editable");
assignId(field, node);
applyBoxProps(field, props);
applyGeneratedLayout(field, layout, style);
const label = stringProp(props.label, "");
if (label) field.append(el("span", "field-label", label));
const control = kind === "control.input.textarea"
? el("textarea", "field-control cultui-field-input") as HTMLTextAreaElement
: el("input", "field-control cultui-field-input") as HTMLInputElement;
if (control instanceof HTMLInputElement) {
control.type = kind.includes("number") || kind === "control.range" ? "number" : "text";
if (props.min !== undefined) control.min = String(props.min);
if (props.max !== undefined) control.max = String(props.max);
if (props.step !== undefined) control.step = String(props.step);
} else if (props.rows !== undefined) {
control.rows = positiveInt(props.rows, 3);
}
control.value = String(context.value ?? "");
control.disabled = context.disabled;
control.name = context.bindingName;
control.dataset.bindingName = context.bindingName;
control.setAttribute("aria-label", label || context.bindingName || node.id || "Input");
if (props.placeholder !== undefined) control.setAttribute("placeholder", String(props.placeholder));
if (boolProp(props.required)) control.required = true;
control.addEventListener("input", () => {
const value = control instanceof HTMLInputElement && control.type === "number"
? (control.value === "" ? "" : Number(control.value))
: control.value;
updateDraft(context, value, options);
});
control.addEventListener("change", () => {
const value = control instanceof HTMLInputElement && control.type === "number"
? (control.value === "" ? "" : Number(control.value))
: control.value;
publishDirectEdit(context, value, node, options);
});
field.append(control);
const validation = firstString(props.validationMessage, props.error, "");
if (validation) {
const message = el("span", "cultui-field-validation", validation);
message.setAttribute("role", "alert");
field.append(message);
control.setAttribute("aria-invalid", "true");
}
return field;
}
function renderChoiceGroup(
node: EveSurfaceComponent,
props: Record<string, unknown>,
children: EveSurfaceComponent[],
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): HTMLElement {
const context = editableBindingContext(node, props, options);
const group = el("fieldset", "field cultui-field cultui-choice-group") as HTMLFieldSetElement;
assignId(group, node);
applyGeneratedLayout(group, layout, style);
const label = firstString(props.label, node.text, context.bindingName);
if (label) group.append(el("legend", "field-label", label));
for (const child of children) {
const optionProps = objectProps(child.props);
const value = firstString(optionProps.value, child.id);
const option = el("label", "cultui-choice");
const input = el("input") as HTMLInputElement;
input.type = boolProp(props.multiple) ? "checkbox" : "radio";
input.name = context.bindingName;
input.value = value;
input.disabled = context.disabled || boolProp(optionProps.disabled);
input.checked = input.type === "checkbox"
? stringArray(context.value).includes(value)
: String(context.value) === value;
input.addEventListener("change", () => {
const next = input.type === "checkbox"
? [...group.querySelectorAll<HTMLInputElement>("input:checked")].map(candidate => candidate.value)
: value;
updateDraft(context, next, options);
publishDirectEdit(context, next, node, options);
});
option.append(input, document.createTextNode(firstString(optionProps.label, child.text, value)));
group.append(option);
}
return group;
}
function renderTabs(
node: EveSurfaceComponent,
props: Record<string, unknown>,
children: EveSurfaceComponent[],
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): HTMLElement {
const context = editableBindingContext(node, { ...props, bind: props.bind || `${node.id || "tabs"}.selected` }, options);
const root = el("section", "cultui-tabs");
assignId(root, node);
applyGeneratedLayout(root, layout, style);
const tabList = el("div", "cultui-tab-list");
tabList.setAttribute("role", "tablist");
const panels = el("div", "cultui-tab-panels");
const selected = firstString(context.value, objectProps(children[0]?.props).value, children[0]?.id);
const activate = (value: string) => {
updateDraft(context, value, options);
for (const button of tabList.querySelectorAll<HTMLButtonElement>("[role=tab]")) {
const active = button.dataset.value === value;
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
}
for (const panel of panels.querySelectorAll<HTMLElement>("[role=tabpanel]")) panel.hidden = panel.dataset.value !== value;
};
for (const [index, child] of children.entries()) {
const childProps = objectProps(child.props);
const value = firstString(childProps.value, child.id, String(index));
const button = el("button", "cultui-tab", firstString(childProps.label, child.text, value)) as HTMLButtonElement;
button.type = "button";
button.dataset.value = value;
button.setAttribute("role", "tab");
button.addEventListener("click", () => activate(value));
tabList.append(button);
const panel = el("section", "cultui-tab-panel");
panel.dataset.value = value;
panel.setAttribute("role", "tabpanel");
for (const content of child.children || []) panel.append(renderEveComponent(content, options));
panels.append(panel);
}
root.append(tabList, panels);
activate(selected);
return root;
}
function renderSelect(
node: EveSurfaceComponent,
props: Record<string, unknown>,
@ -1952,6 +2306,7 @@ function renderSelect(
style: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): HTMLElement {
const context = editableBindingContext(node, props, options);
const field = el("label", "field cultui-field cultui-select");
assignId(field, node);
applyBoxProps(field, props);
@ -1971,13 +2326,16 @@ function renderSelect(
(optionProps.enabled !== undefined && !boolProp(optionProps.enabled));
select.append(option);
}
select.value = stringProp(props.value, "");
select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled));
select.value = String(context.value ?? "");
select.name = context.bindingName;
select.dataset.bindingName = context.bindingName;
select.disabled = context.disabled || (props.enabled !== undefined && !boolProp(props.enabled));
const commandId = resolveComponentCommandId(props, node);
if (commandId) {
select.dataset.commandId = commandId;
select.addEventListener("change", () => {
updateDraft(context, select.value, options);
const intent = createEveCommandIntent(commandId, {
...props,
action: {
@ -1987,6 +2345,11 @@ function renderSelect(
}, options);
void options.commandSink?.(intent, node);
});
} else {
select.addEventListener("change", () => {
updateDraft(context, select.value, options);
publishDirectEdit(context, select.value, node, options);
});
}
field.append(select);
return field;
@ -2206,6 +2569,12 @@ function stringProp(value: unknown, fallback: string): string {
return value === null || value === undefined ? fallback : String(value);
}
function stringArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map(item => String(item).trim()).filter(Boolean);
if (typeof value === "string") return value.split(",").map(item => item.trim()).filter(Boolean);
return [];
}
function firstString(...values: unknown[]): string {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;

View file

@ -1121,3 +1121,50 @@ body[data-pixel-art="true"] .cultui-background {
width: auto;
}
}
.cultui-field-input,
.cultui-field-select {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0.65rem 0.75rem;
color: var(--text, inherit);
background: var(--panel-inset, rgba(0, 0, 0, 0.25));
border: 1px solid var(--border, currentColor);
border-radius: 0.25rem;
font: inherit;
}
textarea.cultui-field-input {
resize: vertical;
}
.cultui-field-validation {
display: block;
color: var(--danger, #ff8e8e);
margin-top: 0.25rem;
}
.cultui-choice-group {
border: 0;
padding: 0;
}
.cultui-choice {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.cultui-tab-list {
display: flex;
gap: 0.25rem;
overflow-x: auto;
}
.cultui-tab[aria-selected="true"] {
border-bottom-color: var(--accent, currentColor);
}
.eve-command-result-region {
margin-top: 0.75rem;
}

View file

@ -49,7 +49,10 @@ test("world interactions emit provider-routed command intents without local auth
},
});
assert.equal(intent.command, "world.move");
assert.equal(intent.operation.operationId, "world.move");
assert.equal(intent.operation.schemaId, "gamecult.eve.operation_payload.v1");
assert.ok(intent.operation.idempotencyKey);
assert.equal(intent.operation.routeHint.sourceVersion, 0);
assert.equal(intent.commandBoundary, "example.commands");
assert.equal(intent.receiptSchema, "example.receipt.v1");
assert.equal(intent.payload.actorEntityId, "entity.player");
@ -75,10 +78,16 @@ test("inventory drops select the provider-advertised operation and preserve spat
}, 7, 9, {
activeSurfaceId: "aetheria.refit",
clientId: "browser.inventory-test",
provider: { providerId: "aetheria", surfaces: [{ surfaceId: "aetheria.refit" }] },
provider: { providerId: "aetheria", surfaces: [{
surfaceId: "aetheria.refit",
worldInteraction: {
commandBoundary: "aetheria.refit.commands",
receiptSchema: "gamecult.eve.command_receipt.v1",
},
}] },
});
assert.equal(intent.command, "aetheria.daemon.commands.EquipItem");
assert.equal(intent.operation.operationId, "aetheria.daemon.commands.EquipItem");
assert.equal(intent.payload.originEntityKey, "zone.0.entity.1");
assert.equal(intent.payload.originCargoIndex, 2);
assert.equal(intent.payload.destinationEntityKey, "zone.0.entity.1");

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { renderEveSurface } from "../dist/index.js";
import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js";
function installDom() {
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='a'></div><div id='b'></div></body></html>", {
@ -104,6 +104,7 @@ test("browser lowering isolates two hosts and patches only the bound component",
assetUrlResolver: uri => `https://alpha.assets${uri}`,
commandSink: intent => alphaCommands.push(intent),
pluginAdapters: [testInputAdapter(() => { alphaInputRenders++; })],
provider: { providerId: "alpha", surfaces: [{ surfaceId: "alpha.hangar", worldInteraction: { commandBoundary: "alpha.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] },
stateBindingResolver: async () => ({
latest: async () => "hydrated",
watch: callback => {
@ -116,6 +117,7 @@ test("browser lowering isolates two hosts and patches only the bound component",
assetUrlResolver: uri => `https://beta.assets${uri}`,
commandSink: intent => betaCommands.push(intent),
pluginAdapters: [testInputAdapter()],
provider: { providerId: "beta", surfaces: [{ surfaceId: "beta.hangar", worldInteraction: { commandBoundary: "beta.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] },
});
await new Promise(resolve => setTimeout(resolve, 0));
@ -177,6 +179,7 @@ test("browser lowering renders command-backed select options", () => {
}, host, {
clientId: "browser-test",
commandSink: intent => commands.push(intent),
provider: { providerId: "hangar-provider", surfaces: [{ surfaceId: "hangar", worldInteraction: { commandBoundary: "hangar.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] },
});
const select = host.querySelector("select");
@ -188,9 +191,76 @@ test("browser lowering renders command-backed select options", () => {
select.value = "gamecult";
select.dispatchEvent(new window.Event("change", { bubbles: true }));
assert.equal(commands.length, 1);
assert.equal(commands[0].command, "hangar.select_verse");
assert.equal(commands[0].operation.operationId, "hangar.select_verse");
assert.equal(commands[0].payload.value, "gamecult");
} finally {
restoreDom();
}
});
test("editable bindings preserve local drafts across authoritative refresh and operations capture them", () => {
const restoreDom = installDom();
try {
const commands = [];
const drafts = new EveBrowserDraftStore();
const host = document.querySelector("#a");
const provider = {
providerId: "ghostlight",
surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: {
commandBoundary: "ghostlight.eve.commands",
receiptSchema: "gamecult.eve.command_result.v1",
} }],
};
const surface = value => ({
providerId: "ghostlight",
version: 7,
commands: [{
command: "session_zero.message.send",
payloadSchema: "session_zero.message.send.v1",
captureBindings: ["composer.message"],
}],
surface: {
id: "ghostlight.play",
root: { id: "root", kind: "column", children: [
{
id: "composer",
kind: "control.input.textarea",
props: { label: "Message", value },
stateBindings: [{
targetProp: "value",
pointerId: "ghostlight.local.composer.message",
sourceId: "eve.browser.local",
schemaId: "gamecult.eve.local_draft.v1",
routeKind: "in-process",
bindingName: "composer.message",
valueKind: "string",
accessMode: "local-draft",
authority: "eve.browser",
}],
},
{ id: "send", kind: "control.button", props: { label: "Send", command: "session_zero.message.send" } },
] },
},
});
const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) };
renderEveSurface(surface(""), host, options);
const composer = host.querySelector("textarea");
composer.value = "The world should remember this.";
composer.dispatchEvent(new window.Event("input", { bubbles: true }));
renderEveSurface(surface("stale provider value"), host, options);
assert.equal(host.querySelector("textarea").value, "The world should remember this.");
host.querySelector("button").click();
assert.deepEqual(commands[0].payload.bindings, {
"composer.message": "The world should remember this.",
});
assert.equal(commands[0].operation.schemaId, "session_zero.message.send.v1");
assert.equal(commands[0].operation.routeHint.sourceVersion, 7);
drafts.clear("ghostlight", "ghostlight.play", ["composer.message"]);
renderEveSurface(surface("accepted provider value"), host, options);
assert.equal(host.querySelector("textarea").value, "accepted provider value");
} finally {
restoreDom();
}
});

View file

@ -7,5 +7,6 @@ export interface EveCommandDescriptor {
authority?: string;
result?: string;
payloadSchema?: string;
captureBindings?: string[];
[k: string]: any;
}

View file

@ -0,0 +1,44 @@
export interface EveCommandResult {
schema: "gamecult.eve.command_result.v1";
receipt: EveCommandReceipt;
transientProjection?: {
schema: "gamecult.eve.surface.v1";
[k: string]: any;
};
pluginPayload?: {
pluginId: string;
schemaId: string;
payload: {
[k: string]: any;
};
};
draftDirective?: {
clear: boolean;
bindingNames?: string[];
};
}
export interface EveCommandReceipt {
schema: "gamecult.eve.command_receipt.v1";
receiptId: string;
commandId: string;
command: string;
state: ("accepted" | "denied" | "pending" | "reconciled");
ownerRepo: string;
authority: string;
providerId: string;
surfaceId: string;
message?: string;
diagnostics?: {
[k: string]: any;
}[];
issuedAtUtc?: string;
sourceVersion: number;
navigation?: {
verseId: string;
providerId?: string;
surfaceId: string;
surfaceKind?: string;
rendezvousEndpoints?: string[];
};
[k: string]: any;
}

View file

@ -0,0 +1 @@
export {};

View file

@ -429,6 +429,30 @@ export declare const eveContractSchemas: {
readonly routeDescription: {
readonly type: "string";
};
readonly bindingName: {
readonly type: "string";
readonly minLength: 1;
};
readonly documentId: {
readonly type: "string";
};
readonly fieldPath: {
readonly type: "string";
};
readonly valueKind: {
readonly type: "string";
readonly enum: readonly ["string", "number", "boolean", "choice", "string-list"];
};
readonly accessMode: {
readonly type: "string";
readonly enum: readonly ["read", "write", "read-write", "local-draft"];
};
readonly authority: {
readonly type: "string";
};
readonly writeCommand: {
readonly type: "string";
};
};
readonly additionalProperties: true;
};
@ -505,6 +529,14 @@ export declare const eveContractSchemas: {
readonly payloadSchema: {
readonly type: "string";
};
readonly captureBindings: {
readonly type: "array";
readonly items: {
readonly type: "string";
readonly minLength: 1;
};
readonly uniqueItems: true;
};
};
readonly additionalProperties: true;
};
@ -654,6 +686,68 @@ export declare const eveContractSchemas: {
};
readonly additionalProperties: true;
};
readonly commandResult: {
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
readonly $id: "gamecult.eve.command_result.v1";
readonly title: "Eve Command Result";
readonly type: "object";
readonly required: readonly ["schema", "receipt"];
readonly properties: {
readonly schema: {
readonly const: "gamecult.eve.command_result.v1";
};
readonly receipt: {
readonly $ref: "gamecult.eve.command_receipt.v1";
};
readonly transientProjection: {
readonly type: "object";
readonly required: readonly ["schema"];
readonly properties: {
readonly schema: {
readonly const: "gamecult.eve.surface.v1";
};
};
readonly additionalProperties: true;
};
readonly pluginPayload: {
readonly type: "object";
readonly required: readonly ["pluginId", "schemaId", "payload"];
readonly properties: {
readonly pluginId: {
readonly type: "string";
readonly minLength: 1;
};
readonly schemaId: {
readonly type: "string";
readonly minLength: 1;
};
readonly payload: {
readonly type: "object";
};
};
readonly additionalProperties: false;
};
readonly draftDirective: {
readonly type: "object";
readonly required: readonly ["clear"];
readonly properties: {
readonly clear: {
readonly type: "boolean";
};
readonly bindingNames: {
readonly type: "array";
readonly items: {
readonly type: "string";
readonly minLength: 1;
};
readonly uniqueItems: true;
};
};
readonly additionalProperties: false;
};
};
readonly additionalProperties: false;
};
readonly inputCapability: {
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
readonly $id: "gamecult.eve.input_capability.v1";

View file

@ -506,6 +506,41 @@ export const eveContractSchemas = {
},
"routeDescription": {
"type": "string"
},
"bindingName": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string"
},
"fieldPath": {
"type": "string"
},
"valueKind": {
"type": "string",
"enum": [
"string",
"number",
"boolean",
"choice",
"string-list"
]
},
"accessMode": {
"type": "string",
"enum": [
"read",
"write",
"read-write",
"local-draft"
]
},
"authority": {
"type": "string"
},
"writeCommand": {
"type": "string"
}
},
"additionalProperties": true
@ -600,6 +635,14 @@ export const eveContractSchemas = {
},
"payloadSchema": {
"type": "string"
},
"captureBindings": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"additionalProperties": true
@ -781,6 +824,79 @@ export const eveContractSchemas = {
},
"additionalProperties": true
},
"commandResult": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.command_result.v1",
"title": "Eve Command Result",
"type": "object",
"required": [
"schema",
"receipt"
],
"properties": {
"schema": {
"const": "gamecult.eve.command_result.v1"
},
"receipt": {
"$ref": "gamecult.eve.command_receipt.v1"
},
"transientProjection": {
"type": "object",
"required": [
"schema"
],
"properties": {
"schema": {
"const": "gamecult.eve.surface.v1"
}
},
"additionalProperties": true
},
"pluginPayload": {
"type": "object",
"required": [
"pluginId",
"schemaId",
"payload"
],
"properties": {
"pluginId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string",
"minLength": 1
},
"payload": {
"type": "object"
}
},
"additionalProperties": false
},
"draftDirective": {
"type": "object",
"required": [
"clear"
],
"properties": {
"clear": {
"type": "boolean"
},
"bindingNames": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"inputCapability": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.input_capability.v1",

View file

@ -39,6 +39,13 @@ export interface Component {
schemaId: string;
routeKind: string;
routeDescription?: string;
bindingName?: string;
documentId?: string;
fieldPath?: string;
valueKind?: ("string" | "number" | "boolean" | "choice" | "string-list");
accessMode?: ("read" | "write" | "read-write" | "local-draft");
authority?: string;
writeCommand?: string;
[k: string]: any;
}[];
embeddedDocuments?: {
@ -62,5 +69,6 @@ export interface EveCommandDescriptor {
authority?: string;
result?: string;
payloadSchema?: string;
captureBindings?: string[];
[k: string]: any;
}

View file

@ -2,6 +2,7 @@ import { type ErrorObject } from "ajv/dist/2020.js";
import type { EveCommandDescriptor } from "./generated/command-descriptor.js";
import type { EveCommandInvocation } from "./generated/command-invocation.js";
import type { EveCommandReceipt } from "./generated/command-receipt.js";
import type { EveCommandResult } from "./generated/command-result.js";
import type { EveInputCapability } from "./generated/input-capability.js";
import type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
import type { EveSurfaceDocument } from "./generated/surface.js";
@ -9,6 +10,7 @@ import { type EveContractSchemaName } from "./generated/schemas.js";
export type { EveCommandDescriptor } from "./generated/command-descriptor.js";
export type { EveCommandInvocation } from "./generated/command-invocation.js";
export type { EveCommandReceipt } from "./generated/command-receipt.js";
export type { EveCommandResult } from "./generated/command-result.js";
export type { EveInputCapability } from "./generated/input-capability.js";
export type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
export type { EveSurfaceDocument } from "./generated/surface.js";
@ -18,6 +20,7 @@ export declare const EVE_SURFACE_SCHEMA: "gamecult.eve.surface.v1";
export declare const EVE_COMMAND_DESCRIPTOR_SCHEMA: "gamecult.eve.command.v1";
export declare const EVE_COMMAND_INVOCATION_SCHEMA: "gamecult.eve.command_invocation.v1";
export declare const EVE_COMMAND_RECEIPT_SCHEMA: "gamecult.eve.command_receipt.v1";
export declare const EVE_COMMAND_RESULT_SCHEMA: "gamecult.eve.command_result.v1";
export declare const EVE_INPUT_CAPABILITY_SCHEMA: "gamecult.eve.input_capability.v1";
type ContractTypes = {
providerAdvertisement: EveProviderAdvertisement;
@ -25,6 +28,7 @@ type ContractTypes = {
commandDescriptor: EveCommandDescriptor;
commandInvocation: EveCommandInvocation;
commandReceipt: EveCommandReceipt;
commandResult: EveCommandResult;
inputCapability: EveInputCapability;
};
export declare class EveContractValidationError extends TypeError {
@ -39,10 +43,12 @@ export declare const isEveSurfaceDocument: (value: unknown) => value is EveSurfa
export declare const isEveCommandDescriptor: (value: unknown) => value is EveCommandDescriptor;
export declare const isEveCommandInvocation: (value: unknown) => value is EveCommandInvocation;
export declare const isEveCommandReceipt: (value: unknown) => value is EveCommandReceipt;
export declare const isEveCommandResult: (value: unknown) => value is EveCommandResult;
export declare const isEveInputCapability: (value: unknown) => value is EveInputCapability;
export declare const parseEveProviderAdvertisement: (value: unknown) => EveProviderAdvertisement;
export declare const parseEveSurfaceDocument: (value: unknown) => EveSurfaceDocument;
export declare const parseEveCommandDescriptor: (value: unknown) => EveCommandDescriptor;
export declare const parseEveCommandInvocation: (value: unknown) => EveCommandInvocation;
export declare const parseEveCommandReceipt: (value: unknown) => EveCommandReceipt;
export declare const parseEveCommandResult: (value: unknown) => EveCommandResult;
export declare const parseEveInputCapability: (value: unknown) => EveInputCapability;

View file

@ -6,6 +6,7 @@ export const EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1";
export const EVE_COMMAND_DESCRIPTOR_SCHEMA = "gamecult.eve.command.v1";
export const EVE_COMMAND_INVOCATION_SCHEMA = "gamecult.eve.command_invocation.v1";
export const EVE_COMMAND_RECEIPT_SCHEMA = "gamecult.eve.command_receipt.v1";
export const EVE_COMMAND_RESULT_SCHEMA = "gamecult.eve.command_result.v1";
export const EVE_INPUT_CAPABILITY_SCHEMA = "gamecult.eve.input_capability.v1";
const ajv = new Ajv2020({
allErrors: true,
@ -41,12 +42,19 @@ export const isEveSurfaceDocument = (value) => isEveContract("surface", value);
export const isEveCommandDescriptor = (value) => isEveContract("commandDescriptor", value);
export const isEveCommandInvocation = (value) => isEveContract("commandInvocation", value);
export const isEveCommandReceipt = (value) => isEveContract("commandReceipt", value);
export const isEveCommandResult = (value) => isEveContract("commandResult", value);
export const isEveInputCapability = (value) => isEveContract("inputCapability", value);
export const parseEveProviderAdvertisement = (value) => parseEveContract("providerAdvertisement", value);
export const parseEveSurfaceDocument = (value) => parseEveContract("surface", value);
export const parseEveCommandDescriptor = (value) => parseEveContract("commandDescriptor", value);
export const parseEveCommandInvocation = (value) => parseEveContract("commandInvocation", value);
export const parseEveCommandReceipt = (value) => parseEveContract("commandReceipt", value);
export const parseEveCommandResult = (value) => {
const result = parseEveContract("commandResult", value);
if (result.transientProjection)
parseEveSurfaceDocument(result.transientProjection);
return result;
};
export const parseEveInputCapability = (value) => parseEveContract("inputCapability", value);
function requiredValidator(schemaId) {
const validator = ajv.getSchema(schemaId);

View file

@ -13,6 +13,7 @@ const contracts = [
["commandDescriptor", "EveCommandDescriptor", "command-descriptor.ts", "gamecult.eve.command.v1.schema.json"],
["commandInvocation", "EveCommandInvocation", "command-invocation.ts", "gamecult.eve.command_invocation.v1.schema.json"],
["commandReceipt", "EveCommandReceipt", "command-receipt.ts", "gamecult.eve.command_receipt.v1.schema.json"],
["commandResult", "EveCommandResult", "command-result.ts", "gamecult.eve.command_result.v1.schema.json"],
["inputCapability", "EveInputCapability", "input-capability.ts", "gamecult.eve.input_capability.v1.schema.json"],
];

View file

@ -9,5 +9,6 @@ transport?: string
authority?: string
result?: string
payloadSchema?: string
captureBindings?: string[]
[k: string]: any
}

View file

@ -0,0 +1,46 @@
// Generated by scripts/generate-contracts.mjs. Do not edit by hand.
// Source: schemas/gamecult.eve.command_result.v1.schema.json
export interface EveCommandResult {
schema: "gamecult.eve.command_result.v1"
receipt: EveCommandReceipt
transientProjection?: {
schema: "gamecult.eve.surface.v1"
[k: string]: any
}
pluginPayload?: {
pluginId: string
schemaId: string
payload: {
[k: string]: any
}
}
draftDirective?: {
clear: boolean
bindingNames?: string[]
}
}
export interface EveCommandReceipt {
schema: "gamecult.eve.command_receipt.v1"
receiptId: string
commandId: string
command: string
state: ("accepted" | "denied" | "pending" | "reconciled")
ownerRepo: string
authority: string
providerId: string
surfaceId: string
message?: string
diagnostics?: {
[k: string]: any
}[]
issuedAtUtc?: string
sourceVersion: number
navigation?: {
verseId: string
providerId?: string
surfaceId: string
surfaceKind?: string
rendezvousEndpoints?: string[]
}
[k: string]: any
}

View file

@ -506,6 +506,41 @@ export const eveContractSchemas = {
},
"routeDescription": {
"type": "string"
},
"bindingName": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string"
},
"fieldPath": {
"type": "string"
},
"valueKind": {
"type": "string",
"enum": [
"string",
"number",
"boolean",
"choice",
"string-list"
]
},
"accessMode": {
"type": "string",
"enum": [
"read",
"write",
"read-write",
"local-draft"
]
},
"authority": {
"type": "string"
},
"writeCommand": {
"type": "string"
}
},
"additionalProperties": true
@ -600,6 +635,14 @@ export const eveContractSchemas = {
},
"payloadSchema": {
"type": "string"
},
"captureBindings": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"additionalProperties": true
@ -781,6 +824,79 @@ export const eveContractSchemas = {
},
"additionalProperties": true
},
"commandResult": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.command_result.v1",
"title": "Eve Command Result",
"type": "object",
"required": [
"schema",
"receipt"
],
"properties": {
"schema": {
"const": "gamecult.eve.command_result.v1"
},
"receipt": {
"$ref": "gamecult.eve.command_receipt.v1"
},
"transientProjection": {
"type": "object",
"required": [
"schema"
],
"properties": {
"schema": {
"const": "gamecult.eve.surface.v1"
}
},
"additionalProperties": true
},
"pluginPayload": {
"type": "object",
"required": [
"pluginId",
"schemaId",
"payload"
],
"properties": {
"pluginId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string",
"minLength": 1
},
"payload": {
"type": "object"
}
},
"additionalProperties": false
},
"draftDirective": {
"type": "object",
"required": [
"clear"
],
"properties": {
"clear": {
"type": "boolean"
},
"bindingNames": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"inputCapability": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.input_capability.v1",

View file

@ -42,6 +42,13 @@ sourceId: string
schemaId: string
routeKind: string
routeDescription?: string
bindingName?: string
documentId?: string
fieldPath?: string
valueKind?: ("string" | "number" | "boolean" | "choice" | "string-list")
accessMode?: ("read" | "write" | "read-write" | "local-draft")
authority?: string
writeCommand?: string
[k: string]: any
}[]
embeddedDocuments?: {
@ -65,5 +72,6 @@ transport?: string
authority?: string
result?: string
payloadSchema?: string
captureBindings?: string[]
[k: string]: any
}

View file

@ -2,6 +2,7 @@ import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020.
import type { EveCommandDescriptor } from "./generated/command-descriptor.js";
import type { EveCommandInvocation } from "./generated/command-invocation.js";
import type { EveCommandReceipt } from "./generated/command-receipt.js";
import type { EveCommandResult } from "./generated/command-result.js";
import type { EveInputCapability } from "./generated/input-capability.js";
import type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
import type { EveSurfaceDocument } from "./generated/surface.js";
@ -10,6 +11,7 @@ import { eveContractSchemas, type EveContractSchemaName } from "./generated/sche
export type { EveCommandDescriptor } from "./generated/command-descriptor.js";
export type { EveCommandInvocation } from "./generated/command-invocation.js";
export type { EveCommandReceipt } from "./generated/command-receipt.js";
export type { EveCommandResult } from "./generated/command-result.js";
export type { EveInputCapability } from "./generated/input-capability.js";
export type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
export type { EveSurfaceDocument } from "./generated/surface.js";
@ -20,6 +22,7 @@ export const EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1" as const;
export const EVE_COMMAND_DESCRIPTOR_SCHEMA = "gamecult.eve.command.v1" as const;
export const EVE_COMMAND_INVOCATION_SCHEMA = "gamecult.eve.command_invocation.v1" as const;
export const EVE_COMMAND_RECEIPT_SCHEMA = "gamecult.eve.command_receipt.v1" as const;
export const EVE_COMMAND_RESULT_SCHEMA = "gamecult.eve.command_result.v1" as const;
export const EVE_INPUT_CAPABILITY_SCHEMA = "gamecult.eve.input_capability.v1" as const;
type ContractTypes = {
@ -28,6 +31,7 @@ type ContractTypes = {
commandDescriptor: EveCommandDescriptor;
commandInvocation: EveCommandInvocation;
commandReceipt: EveCommandReceipt;
commandResult: EveCommandResult;
inputCapability: EveInputCapability;
};
@ -82,6 +86,8 @@ export const isEveCommandInvocation = (value: unknown): value is EveCommandInvoc
isEveContract("commandInvocation", value);
export const isEveCommandReceipt = (value: unknown): value is EveCommandReceipt =>
isEveContract("commandReceipt", value);
export const isEveCommandResult = (value: unknown): value is EveCommandResult =>
isEveContract("commandResult", value);
export const isEveInputCapability = (value: unknown): value is EveInputCapability =>
isEveContract("inputCapability", value);
@ -95,6 +101,11 @@ export const parseEveCommandInvocation = (value: unknown): EveCommandInvocation
parseEveContract("commandInvocation", value);
export const parseEveCommandReceipt = (value: unknown): EveCommandReceipt =>
parseEveContract("commandReceipt", value);
export const parseEveCommandResult = (value: unknown): EveCommandResult => {
const result = parseEveContract("commandResult", value);
if (result.transientProjection) parseEveSurfaceDocument(result.transientProjection);
return result;
};
export const parseEveInputCapability = (value: unknown): EveInputCapability =>
parseEveContract("inputCapability", value);

View file

@ -8,6 +8,7 @@ import {
isEveCommandDescriptor,
parseEveCommandInvocation,
parseEveCommandReceipt,
parseEveCommandResult,
parseEveInputCapability,
parseEveProviderAdvertisement,
parseEveSurfaceDocument,
@ -194,6 +195,33 @@ test("validates an existing canonical Eve surface fixture", async () => {
assert.equal(parseEveSurfaceDocument(fixture).providerId, "eve.world-smoke");
});
test("command results validate persisted receipts and transient Eve projections together", () => {
const result = parseEveCommandResult({
schema: "gamecult.eve.command_result.v1",
receipt: {
schema: "gamecult.eve.command_receipt.v1",
receiptId: "receipt-2",
commandId: "command-2",
command: "session.message.send",
state: "accepted",
ownerRepo: "Ghostlight",
authority: "SessionZeroKernel",
providerId: "ghostlight",
surfaceId: "ghostlight.play",
sourceVersion: 4,
},
transientProjection: surface,
draftDirective: { clear: true, bindingNames: ["composer.message"] },
});
assert.equal(result.receipt.state, "accepted");
assert.equal(result.transientProjection.providerId, "voidbot.swarm");
assert.throws(() => parseEveCommandResult({
schema: "gamecult.eve.command_result.v1",
receipt: result.receipt,
transientProjection: { schema: "gamecult.eve.surface.v1" },
}), EveContractValidationError);
});
test("validates the Epiphany Model Atlas advertisement and canonical surface", async () => {
const fixtureRoot = resolve(import.meta.dirname, "..", "..", "..", "web", "fixtures");
const advertisement = JSON.parse(await readFile(

View file

@ -12,7 +12,12 @@
"transport": { "type": "string" },
"authority": { "type": "string" },
"result": { "type": "string" },
"payloadSchema": { "type": "string" }
"payloadSchema": { "type": "string" },
"captureBindings": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": true
}

View file

@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.command_result.v1",
"title": "Eve Command Result",
"type": "object",
"required": ["schema", "receipt"],
"properties": {
"schema": { "const": "gamecult.eve.command_result.v1" },
"receipt": { "$ref": "gamecult.eve.command_receipt.v1" },
"transientProjection": {
"type": "object",
"required": ["schema"],
"properties": {
"schema": { "const": "gamecult.eve.surface.v1" }
},
"additionalProperties": true
},
"pluginPayload": {
"type": "object",
"required": ["pluginId", "schemaId", "payload"],
"properties": {
"pluginId": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 },
"payload": { "type": "object" }
},
"additionalProperties": false
},
"draftDirective": {
"type": "object",
"required": ["clear"],
"properties": {
"clear": { "type": "boolean" },
"bindingNames": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View file

@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.operation_payload.v1",
"title": "Eve Generic Operation Payload",
"type": "object",
"properties": {
"bindings": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
}

View file

@ -63,7 +63,20 @@
"sourceId": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 },
"routeKind": { "type": "string", "minLength": 1 },
"routeDescription": { "type": "string" }
"routeDescription": { "type": "string" },
"bindingName": { "type": "string", "minLength": 1 },
"documentId": { "type": "string" },
"fieldPath": { "type": "string" },
"valueKind": {
"type": "string",
"enum": ["string", "number", "boolean", "choice", "string-list"]
},
"accessMode": {
"type": "string",
"enum": ["read", "write", "read-write", "local-draft"]
},
"authority": { "type": "string" },
"writeCommand": { "type": "string" }
},
"additionalProperties": true
}