diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 097d822..c102a7b 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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; diff --git a/docs/surface-contract-v1.md b/docs/surface-contract-v1.md index bb7b9aa..b8a7039 100644 --- a/docs/surface-contract-v1.md +++ b/docs/surface-contract-v1.md @@ -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 diff --git a/packages/eve-browser-lowering/README.md b/packages/eve-browser-lowering/README.md index ffe883f..f776573 100644 --- a/packages/eve-browser-lowering/README.md +++ b/packages/eve-browser-lowering/README.md @@ -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 `
` 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. diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts index a25d7de..6907489 100644 --- a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts @@ -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; + }, result: unknown, context: { + provider: unknown; + surface: unknown; + }) => void | Promise; renderComponent?: (component: FieldsComponent, props: Record, layout: Record, style: Record, options: FieldsLoweringOptions, context: FieldsRuntimeContext) => HTMLElement; } export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter; diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index cbb8b83..51d9a3d 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -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; @@ -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; @@ -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; - 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; + commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise; documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; pluginAdapters?: readonly EveBrowserPluginAdapter[]; stateBindingResolver?: EveStateBindingResolver; source?: string; statusElement?: HTMLElement; + draftStore?: EveBrowserDraftStore; } export interface EveBrowserProviderTransport { providerAdvertisement(): Promise; @@ -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; +} 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; stop(): void; refresh(): Promise; private submit; + private consumeCommandResult; + private presentCommandStatus; } export interface EveEmbeddedDocumentRequest { documentId: string; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index cdd4e5c..018dcbd 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -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()) diff --git a/packages/eve-browser-lowering/package-lock.json b/packages/eve-browser-lowering/package-lock.json index adbc042..0947e8c 100644 --- a/packages/eve-browser-lowering/package-lock.json +++ b/packages/eve-browser-lowering/package-lock.json @@ -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", diff --git a/packages/eve-browser-lowering/package.json b/packages/eve-browser-lowering/package.json index f9103ce..c2163b3 100644 --- a/packages/eve-browser-lowering/package.json +++ b/packages/eve-browser-lowering/package.json @@ -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" diff --git a/packages/eve-browser-lowering/src/fields-browser-adapter.ts b/packages/eve-browser-lowering/src/fields-browser-adapter.ts index 0940a6f..3e24aa0 100644 --- a/packages/eve-browser-lowering/src/fields-browser-adapter.ts +++ b/packages/eve-browser-lowering/src/fields-browser-adapter.ts @@ -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 }, + result: unknown, + context: { provider: unknown; surface: unknown }, + ) => void | Promise; renderComponent?: ( component: FieldsComponent, props: Record, diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 7234da2..4ce39f0 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -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; - 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; + commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise; documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; 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(); + + 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 { + 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 { - 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 { - 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 { + 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(":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 = { const loadedFontStylesheets = new Map(); const activeSurfaceBindings = new WeakMap(); +const surfaceDraftStores = new WeakMap(); interface NormalizedStyles { tokens: Record; @@ -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): Record { + 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, + 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, + layout: Record, + style: Record, + 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, + children: EveSurfaceComponent[], + layout: Record, + style: Record, + 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("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, + children: EveSurfaceComponent[], + layout: Record, + style: Record, + 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("[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)) 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, @@ -1952,6 +2306,7 @@ function renderSelect( style: Record, 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; diff --git a/packages/eve-browser-lowering/styles.css b/packages/eve-browser-lowering/styles.css index 789b7fa..825305d 100644 --- a/packages/eve-browser-lowering/styles.css +++ b/packages/eve-browser-lowering/styles.css @@ -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; +} diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index b4cbb2d..05069fe 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -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"); diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 55ed2ca..952efb8 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; -import { renderEveSurface } from "../dist/index.js"; +import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js"; function installDom() { const dom = new JSDOM("
", { @@ -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(); + } +}); diff --git a/packages/eve-contracts/dist/generated/command-descriptor.d.ts b/packages/eve-contracts/dist/generated/command-descriptor.d.ts index 9e8341f..cc2a607 100644 --- a/packages/eve-contracts/dist/generated/command-descriptor.d.ts +++ b/packages/eve-contracts/dist/generated/command-descriptor.d.ts @@ -7,5 +7,6 @@ export interface EveCommandDescriptor { authority?: string; result?: string; payloadSchema?: string; + captureBindings?: string[]; [k: string]: any; } diff --git a/packages/eve-contracts/dist/generated/command-result.d.ts b/packages/eve-contracts/dist/generated/command-result.d.ts new file mode 100644 index 0000000..c59e932 --- /dev/null +++ b/packages/eve-contracts/dist/generated/command-result.d.ts @@ -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; +} diff --git a/packages/eve-contracts/dist/generated/command-result.js b/packages/eve-contracts/dist/generated/command-result.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/eve-contracts/dist/generated/command-result.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/eve-contracts/dist/generated/schemas.d.ts b/packages/eve-contracts/dist/generated/schemas.d.ts index 0a823e6..98723d3 100644 --- a/packages/eve-contracts/dist/generated/schemas.d.ts +++ b/packages/eve-contracts/dist/generated/schemas.d.ts @@ -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"; diff --git a/packages/eve-contracts/dist/generated/schemas.js b/packages/eve-contracts/dist/generated/schemas.js index 88188b2..7aacb44 100644 --- a/packages/eve-contracts/dist/generated/schemas.js +++ b/packages/eve-contracts/dist/generated/schemas.js @@ -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", diff --git a/packages/eve-contracts/dist/generated/surface.d.ts b/packages/eve-contracts/dist/generated/surface.d.ts index f458ab6..0e65fa2 100644 --- a/packages/eve-contracts/dist/generated/surface.d.ts +++ b/packages/eve-contracts/dist/generated/surface.d.ts @@ -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; } diff --git a/packages/eve-contracts/dist/index.d.ts b/packages/eve-contracts/dist/index.d.ts index d36f7e8..3e5c900 100644 --- a/packages/eve-contracts/dist/index.d.ts +++ b/packages/eve-contracts/dist/index.d.ts @@ -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; diff --git a/packages/eve-contracts/dist/index.js b/packages/eve-contracts/dist/index.js index 9320db7..467a936 100644 --- a/packages/eve-contracts/dist/index.js +++ b/packages/eve-contracts/dist/index.js @@ -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); diff --git a/packages/eve-contracts/scripts/generate-contracts.mjs b/packages/eve-contracts/scripts/generate-contracts.mjs index 4ad587f..b3a963a 100644 --- a/packages/eve-contracts/scripts/generate-contracts.mjs +++ b/packages/eve-contracts/scripts/generate-contracts.mjs @@ -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"], ]; diff --git a/packages/eve-contracts/src/generated/command-descriptor.ts b/packages/eve-contracts/src/generated/command-descriptor.ts index 8ba22bd..1140fc9 100644 --- a/packages/eve-contracts/src/generated/command-descriptor.ts +++ b/packages/eve-contracts/src/generated/command-descriptor.ts @@ -9,5 +9,6 @@ transport?: string authority?: string result?: string payloadSchema?: string +captureBindings?: string[] [k: string]: any } diff --git a/packages/eve-contracts/src/generated/command-result.ts b/packages/eve-contracts/src/generated/command-result.ts new file mode 100644 index 0000000..f6351e0 --- /dev/null +++ b/packages/eve-contracts/src/generated/command-result.ts @@ -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 +} diff --git a/packages/eve-contracts/src/generated/schemas.ts b/packages/eve-contracts/src/generated/schemas.ts index a77d3e3..736b0c0 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -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", diff --git a/packages/eve-contracts/src/generated/surface.ts b/packages/eve-contracts/src/generated/surface.ts index 8565b9b..b6d080a 100644 --- a/packages/eve-contracts/src/generated/surface.ts +++ b/packages/eve-contracts/src/generated/surface.ts @@ -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 } diff --git a/packages/eve-contracts/src/index.ts b/packages/eve-contracts/src/index.ts index 518297d..a32201a 100644 --- a/packages/eve-contracts/src/index.ts +++ b/packages/eve-contracts/src/index.ts @@ -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); diff --git a/packages/eve-contracts/test/contracts.test.mjs b/packages/eve-contracts/test/contracts.test.mjs index 3650a5f..1b63c50 100644 --- a/packages/eve-contracts/test/contracts.test.mjs +++ b/packages/eve-contracts/test/contracts.test.mjs @@ -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( diff --git a/schemas/gamecult.eve.command.v1.schema.json b/schemas/gamecult.eve.command.v1.schema.json index f66b28d..821ee47 100644 --- a/schemas/gamecult.eve.command.v1.schema.json +++ b/schemas/gamecult.eve.command.v1.schema.json @@ -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 } diff --git a/schemas/gamecult.eve.command_result.v1.schema.json b/schemas/gamecult.eve.command_result.v1.schema.json new file mode 100644 index 0000000..f8c08ad --- /dev/null +++ b/schemas/gamecult.eve.command_result.v1.schema.json @@ -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 +} diff --git a/schemas/gamecult.eve.operation_payload.v1.schema.json b/schemas/gamecult.eve.operation_payload.v1.schema.json new file mode 100644 index 0000000..bf79a1b --- /dev/null +++ b/schemas/gamecult.eve.operation_payload.v1.schema.json @@ -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 +} diff --git a/schemas/gamecult.eve.surface.v1.schema.json b/schemas/gamecult.eve.surface.v1.schema.json index 0d54f49..8c5e8c5 100644 --- a/schemas/gamecult.eve.surface.v1.schema.json +++ b/schemas/gamecult.eve.surface.v1.schema.json @@ -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 }