From 75c472b368b349c5cf392285925000f34c3415d3 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:14:01 +0200 Subject: [PATCH 01/58] feat: define binding authoring model contracts --- src/bindingAuthoringContracts.ts | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/bindingAuthoringContracts.ts diff --git a/src/bindingAuthoringContracts.ts b/src/bindingAuthoringContracts.ts new file mode 100644 index 0000000..1992fa6 --- /dev/null +++ b/src/bindingAuthoringContracts.ts @@ -0,0 +1,56 @@ +import type { + BindingOperationRef, + DataSourceDiagnostic, + UiBindableEventMeta, + UiBindablePropMeta, + UiBindableValueMeta, +} from '@ankhorage/contracts'; + +export type StudioBindingCompatibility = 'compatible' | 'incompatible' | 'unknown'; + +export interface StudioBindablePropOption { + readonly name: string; + readonly label: string; + readonly meta: UiBindablePropMeta; +} + +export interface StudioBindableEventOption { + readonly name: string; + readonly label: string; + readonly meta: UiBindableEventMeta; +} + +export interface StudioBindingInputFieldOption { + readonly name: string; + readonly label: string; + readonly value: UiBindableValueMeta; + readonly required: boolean; +} + +export interface StudioBindingResponsePathOption { + readonly path: string; + readonly label: string; + readonly value: UiBindableValueMeta; +} + +export interface StudioBindingOperationOption { + readonly operation: BindingOperationRef; + readonly label: string; + readonly sourceLabel: string; + readonly inputFields: readonly StudioBindingInputFieldOption[]; + readonly responsePaths: readonly StudioBindingResponsePathOption[]; +} + +export interface StudioBindingDiagnostic { + readonly code: + | 'incompatible-response' + | 'missing-action' + | 'missing-binding-meta' + | 'missing-response-path' + | 'unknown-event' + | 'unknown-prop'; + readonly message: string; + readonly severity: 'error' | 'warning'; + readonly path?: string; + readonly runtimeDiagnostics?: readonly DataSourceDiagnostic[]; +} From 5a0e9ab966ba6f81b9aabf6205bfff1ece8fda2c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:14:11 +0200 Subject: [PATCH 02/58] feat: resolve bindable component metadata --- src/bindingMetadataModel.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/bindingMetadataModel.ts diff --git a/src/bindingMetadataModel.ts b/src/bindingMetadataModel.ts new file mode 100644 index 0000000..ea60c3f --- /dev/null +++ b/src/bindingMetadataModel.ts @@ -0,0 +1,29 @@ +import type { UiComponentMetaRegistry, UiNode } from '@ankhorage/contracts'; + +import type { StudioBindableEventOption, StudioBindablePropOption } from './bindingAuthoringContracts'; + +export function resolveStudioBindableProps( + node: UiNode, + registry: UiComponentMetaRegistry, +): readonly StudioBindablePropOption[] { + const props = registry[node.type]?.bindings?.props ?? {}; + + return Object.entries(props).map(([name, meta]) => ({ + name, + label: meta.label ?? name, + meta, + })); +} + +export function resolveStudioBindableEvents( + node: UiNode, + registry: UiComponentMetaRegistry, +): readonly StudioBindableEventOption[] { + const events = registry[node.type]?.bindings?.events ?? {}; + + return Object.entries(events).map(([name, meta]) => ({ + name, + label: meta.label ?? name, + meta, + })); +} From 8c91a28a96a415ead68a7b000eeeb4ad451b411b Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:14:23 +0200 Subject: [PATCH 03/58] feat: add canonical binding registry mutations --- src/bindingMutationModel.ts | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/bindingMutationModel.ts diff --git a/src/bindingMutationModel.ts b/src/bindingMutationModel.ts new file mode 100644 index 0000000..7978f71 --- /dev/null +++ b/src/bindingMutationModel.ts @@ -0,0 +1,84 @@ +import type { + ComponentDataBinding, + ComponentDataBindingRegistry, + EventBinding, + PropBinding, + UiNode, +} from '@ankhorage/contracts'; + +export function upsertStudioPropBinding( + registry: ComponentDataBindingRegistry, + node: UiNode, + propName: string, + binding: PropBinding, +): ComponentDataBindingRegistry { + const current = registry[node.id]; + return writeBinding(registry, node, { + ...current, + props: { ...(current?.props ?? {}), [propName]: binding }, + }); +} + +export function removeStudioPropBinding( + registry: ComponentDataBindingRegistry, + node: UiNode, + propName: string, +): ComponentDataBindingRegistry { + const current = registry[node.id]; + if (!current?.props?.[propName]) return registry; + const props = { ...current.props }; + delete props[propName]; + return writeBinding(registry, node, { ...current, props }); +} + +export function appendStudioEventBinding( + registry: ComponentDataBindingRegistry, + node: UiNode, + eventName: string, + binding: EventBinding, +): ComponentDataBindingRegistry { + const current = registry[node.id]; + return writeBinding(registry, node, { + ...current, + events: { + ...(current?.events ?? {}), + [eventName]: [...(current?.events?.[eventName] ?? []), binding], + }, + }); +} + +export function removeStudioEventBinding( + registry: ComponentDataBindingRegistry, + node: UiNode, + eventName: string, + bindingIndex: number, +): ComponentDataBindingRegistry { + const current = registry[node.id]; + const bindings = current?.events?.[eventName]; + if (!bindings?.[bindingIndex]) return registry; + const events = { ...(current.events ?? {}) }; + const next = bindings.filter((_, index) => index !== bindingIndex); + if (next.length > 0) events[eventName] = next; + else delete events[eventName]; + return writeBinding(registry, node, { ...current, events }); +} + +function writeBinding( + registry: ComponentDataBindingRegistry, + node: UiNode, + value: Omit, +): ComponentDataBindingRegistry { + const next = { ...registry }; + const binding: ComponentDataBinding = { + ...value, + componentId: node.id, + componentType: node.type, + }; + if (isEmptyBinding(binding)) delete next[node.id]; + else next[node.id] = binding; + return next; +} + +function isEmptyBinding(binding: ComponentDataBinding): boolean { + return Object.keys(binding.props ?? {}).length === 0 && Object.keys(binding.events ?? {}).length === 0; +} From 5b51711df0d031763f0c534b8ab8af0d87a396d2 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:14:44 +0200 Subject: [PATCH 04/58] feat: resolve binding schema compatibility --- src/bindingSchemaModel.ts | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/bindingSchemaModel.ts diff --git a/src/bindingSchemaModel.ts b/src/bindingSchemaModel.ts new file mode 100644 index 0000000..b29903f --- /dev/null +++ b/src/bindingSchemaModel.ts @@ -0,0 +1,111 @@ +import type { + DataSchema, + DataSchemaRegistry, + UiBindableValueFieldMeta, + UiBindableValueMeta, +} from '@ankhorage/contracts'; + +import type { StudioBindingCompatibility, StudioBindingResponsePathOption } from './bindingAuthoringContracts'; + +export function resolveStudioSchemaValueMeta( + schema: DataSchema | undefined, + schemas: DataSchemaRegistry | undefined, + seen: ReadonlySet = new Set(), +): UiBindableValueMeta { + const resolved = resolveSchemaRef(schema, schemas, seen); + if (!resolved) return { type: 'unknown' }; + const type = resolveSchemaType(resolved); + const fields = resolveSchemaFields(resolved, schemas, seen); + const itemType = + type === 'array' ? resolveStudioSchemaValueMeta(resolved.items, schemas, seen).type : undefined; + + return { + type, + ...(fields.length > 0 ? { fields } : {}), + ...(itemType ? { itemType } : {}), + }; +} + +export function collectStudioResponsePaths( + schema: DataSchema | undefined, + schemas: DataSchemaRegistry | undefined, +): readonly StudioBindingResponsePathOption[] { + const root = resolveStudioSchemaValueMeta(schema, schemas); + const paths: StudioBindingResponsePathOption[] = [{ path: '', label: 'Response', value: root }]; + collectObjectPaths(schema, schemas, '', paths, new Set()); + return paths; +} + +export function assessStudioBindingCompatibility( + expected: UiBindableValueMeta, + actual: UiBindableValueMeta, +): StudioBindingCompatibility { + if (expected.type === 'unknown' || actual.type === 'unknown') return 'unknown'; + if (expected.type === actual.type) { + if (expected.type !== 'array') return 'compatible'; + if (!expected.itemType || !actual.itemType) return 'unknown'; + return expected.itemType === actual.itemType ? 'compatible' : 'incompatible'; + } + if (isObjectLike(expected.type) && isObjectLike(actual.type)) return 'compatible'; + if (expected.type === 'imageAsset' && isImageAssetShape(actual)) return 'compatible'; + return 'incompatible'; +} + +function collectObjectPaths( + schema: DataSchema | undefined, + schemas: DataSchemaRegistry | undefined, + prefix: string, + paths: StudioBindingResponsePathOption[], + seen: Set, +): void { + const resolved = resolveSchemaRef(schema, schemas, seen); + if (!resolved?.properties) return; + for (const [name, property] of Object.entries(resolved.properties)) { + const path = prefix ? `${prefix}.${name}` : name; + paths.push({ path, label: path, value: resolveStudioSchemaValueMeta(property, schemas, seen) }); + collectObjectPaths(property, schemas, path, paths, new Set(seen)); + } +} + +function resolveSchemaRef( + schema: DataSchema | undefined, + schemas: DataSchemaRegistry | undefined, + seen: ReadonlySet, +): DataSchema | undefined { + const refId = schema?.ref?.id; + if (!refId || !schemas?.[refId] || seen.has(refId)) return schema; + return resolveSchemaRef(schemas[refId], schemas, new Set([...seen, refId])); +} + +function resolveSchemaFields( + schema: DataSchema, + schemas: DataSchemaRegistry | undefined, + seen: ReadonlySet, +): readonly UiBindableValueFieldMeta[] { + return Object.entries(schema.properties ?? {}).map(([path, property]) => ({ + path, + type: resolveStudioSchemaValueMeta(property, schemas, seen).type, + required: schema.required?.includes(path) ?? false, + })); +} + +function resolveSchemaType(schema: DataSchema): UiBindableValueMeta['type'] { + if (schema.format === 'date' || schema.format === 'date-time') return 'date'; + const rawType = Array.isArray(schema.type) ? (schema.type.length === 1 ? schema.type[0] : undefined) : schema.type; + if (rawType === 'integer') return 'number'; + if (rawType === 'object') return schema.additionalProperties ? 'record' : 'object'; + if (rawType === 'array' || rawType === 'boolean' || rawType === 'number' || rawType === 'string') { + return rawType; + } + if (!rawType && schema.properties) return 'object'; + if (!rawType && schema.items) return 'array'; + return 'unknown'; +} + +function isObjectLike(type: UiBindableValueMeta['type']): boolean { + return type === 'object' || type === 'record'; +} + +function isImageAssetShape(value: UiBindableValueMeta): boolean { + return isObjectLike(value.type) && value.fields?.some((field) => field.path === 'uri' && field.type === 'string') === true; +} From b299d00998d4f373e5ce46f2a59f20d13206307a Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:15:01 +0200 Subject: [PATCH 05/58] feat: enumerate canonical binding operations --- src/bindingOperationModel.ts | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/bindingOperationModel.ts diff --git a/src/bindingOperationModel.ts b/src/bindingOperationModel.ts new file mode 100644 index 0000000..f8e9678 --- /dev/null +++ b/src/bindingOperationModel.ts @@ -0,0 +1,109 @@ +import type { + BindingOperationRef, + DataOperationConfig, + DataSchema, + DataSourceConfig, + DataSourceRegistry, + UiBindableValueMeta, +} from '@ankhorage/contracts'; + +import type { + StudioBindingInputFieldOption, + StudioBindingOperationOption, +} from './bindingAuthoringContracts'; +import { collectStudioResponsePaths, resolveStudioSchemaValueMeta } from './bindingSchemaModel'; + +export function collectStudioBindingOperationOptions( + dataSources: DataSourceRegistry, +): readonly StudioBindingOperationOption[] { + return Object.values(dataSources) + .flatMap((source) => collectSourceOperations(source)) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +export function findStudioBindingOperationOption( + options: readonly StudioBindingOperationOption[], + ref: BindingOperationRef, +): StudioBindingOperationOption | undefined { + return options.find( + (option) => + option.operation.dataSourceId === ref.dataSourceId && + option.operation.operationId === ref.operationId && + option.operation.endpointId === ref.endpointId, + ); +} + +function collectSourceOperations(source: DataSourceConfig): StudioBindingOperationOption[] { + return Object.values(source.endpoints).flatMap((endpoint) => + Object.values(endpoint.operations).map((operation) => ({ + operation: { + dataSourceId: source.id, + endpointId: endpoint.id, + operationId: operation.id, + }, + label: `${source.name ?? source.id} · ${operation.name ?? operation.id}`, + sourceLabel: describeSource(source), + inputFields: collectOperationInputFields(source, operation), + responsePaths: collectStudioResponsePaths( + resolveSlotSchema(source, operation.response), + source.schemas, + ), + })), + ); +} + +function collectOperationInputFields( + source: DataSourceConfig, + operation: DataOperationConfig, +): readonly StudioBindingInputFieldOption[] { + const fields = new Map(); + for (const parameter of operation.request?.parameters ?? []) { + fields.set(parameter.name, { + name: parameter.name, + label: parameter.description ?? parameter.name, + value: resolveStudioSchemaValueMeta(resolveSlotSchema(source, parameter), source.schemas), + required: parameter.required ?? false, + }); + } + + const requestSchema = resolveSlotSchema(source, operation.request); + for (const [name, schema] of Object.entries(requestSchema?.properties ?? {})) { + if (fields.has(name)) continue; + fields.set(name, { + name, + label: schema.title ?? name, + value: resolveStudioSchemaValueMeta(schema, source.schemas), + required: requestSchema?.required?.includes(name) ?? false, + }); + } + + return [...fields.values()]; +} + +function resolveSlotSchema( + source: DataSourceConfig, + slot: { readonly schema?: DataSchema; readonly schemaRef?: { readonly id: string } } | undefined, +): DataSchema | undefined { + return slot?.schema ?? (slot?.schemaRef ? source.schemas?.[slot.schemaRef.id] : undefined); +} + +function describeSource(source: DataSourceConfig): string { + if (source.kind === 'database') return `${source.id} · database`; + return `${source.id} · ${source.origin} · ${source.protocol}`; +} + +export function createStudioActionInputFields( + payloadSchema: Readonly> | undefined, +): readonly StudioBindingInputFieldOption[] { + return Object.entries(payloadSchema ?? {}).map(([name, field]) => ({ + name, + label: field.label, + value: { type: toBindableType(field.type) }, + required: field.required ?? false, + })); +} + +function toBindableType(type: string): UiBindableValueMeta['type'] { + if (type === 'string' || type === 'number' || type === 'boolean' || type === 'object') return type; + return 'unknown'; +} From 7c65fe58e93c2c3547d6b699a2daf80701e0209c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:15:29 +0200 Subject: [PATCH 06/58] feat: expand binding diagnostics contracts --- src/bindingAuthoringContracts.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bindingAuthoringContracts.ts b/src/bindingAuthoringContracts.ts index 1992fa6..6edd136 100644 --- a/src/bindingAuthoringContracts.ts +++ b/src/bindingAuthoringContracts.ts @@ -43,9 +43,12 @@ export interface StudioBindingOperationOption { export interface StudioBindingDiagnostic { readonly code: + | 'incompatible-input' | 'incompatible-response' | 'missing-action' | 'missing-binding-meta' + | 'missing-input' + | 'missing-operation' | 'missing-response-path' | 'unknown-event' | 'unknown-prop'; From 0754f9a2cc073a6c5285ad44d721e14428405ddd Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:15:49 +0200 Subject: [PATCH 07/58] feat: diagnose authored component bindings --- src/bindingDiagnosticsModel.ts | 146 +++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/bindingDiagnosticsModel.ts diff --git a/src/bindingDiagnosticsModel.ts b/src/bindingDiagnosticsModel.ts new file mode 100644 index 0000000..032f618 --- /dev/null +++ b/src/bindingDiagnosticsModel.ts @@ -0,0 +1,146 @@ +import type { + BindingInputValue, + ComponentDataBindingRegistry, + DataSourceRegistry, + EventBinding, + PropBinding, + UiComponentMetaRegistry, + UiNode, +} from '@ankhorage/contracts'; +import { validateRuntimeBindingOperationRef } from '@ankhorage/runtime'; + +import type { + StudioBindingDiagnostic, + StudioBindingInputFieldOption, + StudioBindingOperationOption, +} from './bindingAuthoringContracts'; +import { findStudioBindingOperationOption } from './bindingOperationModel'; +import { assessStudioBindingCompatibility } from './bindingSchemaModel'; + +export function diagnoseStudioComponentBindings(args: { + readonly node: UiNode; + readonly registry: ComponentDataBindingRegistry; + readonly componentMeta: UiComponentMetaRegistry; + readonly dataSources: DataSourceRegistry; + readonly operations: readonly StudioBindingOperationOption[]; + readonly actionTypes: readonly string[]; +}): readonly StudioBindingDiagnostic[] { + const binding = args.registry[args.node.id]; + if (!binding) return []; + const meta = args.componentMeta[args.node.type]?.bindings; + if (!meta) { + return [diagnostic('missing-binding-meta', 'This component exposes no canonical binding metadata.')]; + } + + return [ + ...Object.entries(binding.props ?? {}).flatMap(([name, prop]) => + diagnosePropBinding(name, prop, meta.props?.[name], args), + ), + ...Object.entries(binding.events ?? {}).flatMap(([name, events]) => + events.flatMap((event, index) => diagnoseEventBinding(name, index, event, meta.events?.[name], args)), + ), + ]; +} + +function diagnosePropBinding( + name: string, + binding: PropBinding, + meta: UiComponentMetaRegistry[string]['bindings'] extends infer _T ? unknown : never, + args: Parameters[0], +): readonly StudioBindingDiagnostic[] { + const propMeta = args.componentMeta[args.node.type]?.bindings?.props?.[name]; + if (!propMeta) return [diagnostic('unknown-prop', `Property '${name}' is not bindable.`, `props.${name}`)]; + if (binding.source.kind !== 'operation') return []; + + const runtimeDiagnostics = validateRuntimeBindingOperationRef(binding.source.operation, args.dataSources); + if (runtimeDiagnostics.length > 0) { + return [ + { + ...diagnostic('missing-operation', runtimeDiagnostics[0]?.message ?? 'Operation is unavailable.', `props.${name}`), + runtimeDiagnostics, + }, + ]; + } + + const operation = findStudioBindingOperationOption(args.operations, binding.source.operation); + const path = binding.source.path ?? ''; + const response = operation?.responsePaths.find((candidate) => candidate.path === path); + if (!response) { + return [diagnostic('missing-response-path', `Response path '${path || ''}' is unavailable.`, `props.${name}`)]; + } + + const compatibility = assessStudioBindingCompatibility(propMeta.value, response.value); + return compatibility === 'incompatible' + ? [diagnostic('incompatible-response', `Response '${response.label}' is incompatible with ${propMeta.value.type}.`, `props.${name}`)] + : []; +} + +function diagnoseEventBinding( + eventName: string, + index: number, + binding: EventBinding, + _meta: unknown, + args: Parameters[0], +): readonly StudioBindingDiagnostic[] { + const eventMeta = args.componentMeta[args.node.type]?.bindings?.events?.[eventName]; + const path = `events.${eventName}.${index}`; + if (!eventMeta) return [diagnostic('unknown-event', `Event '${eventName}' is not bindable.`, path)]; + + if (binding.target.kind === 'action') { + if (!args.actionTypes.includes(binding.target.type)) { + return [diagnostic('missing-action', `Action '${binding.target.type}' is unavailable.`, path)]; + } + return []; + } + + const runtimeDiagnostics = validateRuntimeBindingOperationRef(binding.target.operation, args.dataSources); + if (runtimeDiagnostics.length > 0) { + return [{ ...diagnostic('missing-operation', runtimeDiagnostics[0]?.message ?? 'Operation is unavailable.', path), runtimeDiagnostics }]; + } + + const operation = findStudioBindingOperationOption(args.operations, binding.target.operation); + return diagnoseEventInputs(binding, operation?.inputFields ?? [], eventMeta.payload?.fields ?? [], path); +} + +function diagnoseEventInputs( + binding: EventBinding, + fields: readonly StudioBindingInputFieldOption[], + eventFields: readonly { readonly path: string; readonly type: string }[], + path: string, +): readonly StudioBindingDiagnostic[] { + return fields.flatMap((field) => { + const input = binding.input?.[field.name]; + if (!input) { + return field.required ? [diagnostic('missing-input', `Required input '${field.name}' is not mapped.`, `${path}.input.${field.name}`)] : []; + } + return diagnoseInputCompatibility(input, field, eventFields, `${path}.input.${field.name}`); + }); +} + +function diagnoseInputCompatibility( + input: BindingInputValue, + field: StudioBindingInputFieldOption, + eventFields: readonly { readonly path: string; readonly type: string }[], + path: string, +): readonly StudioBindingDiagnostic[] { + if (input.kind !== 'source' || input.source.kind !== 'event') return []; + const eventField = eventFields.find((candidate) => candidate.path === input.source.path); + if (!eventField) return [diagnostic('incompatible-input', `Event path '${input.source.path}' is unavailable.`, path)]; + const compatibility = assessStudioBindingCompatibility(field.value, { type: toBindableType(eventField.type) }); + return compatibility === 'incompatible' + ? [diagnostic('incompatible-input', `Event field '${eventField.path}' is incompatible with ${field.value.type}.`, path)] + : []; +} + +function toBindableType(type: string) { + if (type === 'string' || type === 'number' || type === 'boolean' || type === 'object' || type === 'record') return type; + return 'unknown' as const; +} + +function diagnostic( + code: StudioBindingDiagnostic['code'], + message: string, + path?: string, +): StudioBindingDiagnostic { + return { code, message, severity: 'error', ...(path ? { path } : {}) }; +} From 6f4e6c8d658f2f7c82e856e4a1b1c8785ed087d5 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:16:01 +0200 Subject: [PATCH 08/58] feat: expose binding authoring model --- src/bindingAuthoringModel.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/bindingAuthoringModel.ts diff --git a/src/bindingAuthoringModel.ts b/src/bindingAuthoringModel.ts new file mode 100644 index 0000000..bfb0909 --- /dev/null +++ b/src/bindingAuthoringModel.ts @@ -0,0 +1,6 @@ +export * from './bindingAuthoringContracts'; +export * from './bindingDiagnosticsModel'; +export * from './bindingMetadataModel'; +export * from './bindingMutationModel'; +export * from './bindingOperationModel'; +export * from './bindingSchemaModel'; From fbe5d7f6816bd5fbaebba542e263aef84dc4c2fd Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:16:30 +0200 Subject: [PATCH 09/58] test: cover canonical binding authoring model --- src/bindingAuthoringModel.test.ts | 177 ++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/bindingAuthoringModel.test.ts diff --git a/src/bindingAuthoringModel.test.ts b/src/bindingAuthoringModel.test.ts new file mode 100644 index 0000000..a152e68 --- /dev/null +++ b/src/bindingAuthoringModel.test.ts @@ -0,0 +1,177 @@ +import type { + ComponentDataBindingRegistry, + DataSourceRegistry, + PropBinding, + UiNode, +} from '@ankhorage/contracts'; +import { ZORA_BINDABLE_COMPONENT_META } from '@ankhorage/zora'; +import { describe, expect, test } from 'bun:test'; + +import { + appendStudioEventBinding, + assessStudioBindingCompatibility, + collectStudioBindingOperationOptions, + diagnoseStudioComponentBindings, + removeStudioEventBinding, + removeStudioPropBinding, + resolveStudioBindableEvents, + resolveStudioBindableProps, + upsertStudioPropBinding, +} from './bindingAuthoringModel'; + +const button: UiNode = { id: 'button-1', type: 'Button', props: { children: 'Save' } }; + +const dataSources: DataSourceRegistry = { + external: { + id: 'external', + kind: 'api', + origin: 'external', + protocol: 'rest', + baseUrl: 'https://example.test', + endpoints: { + profile: { + id: 'profile', + kind: 'http', + operations: { + 'profile.read': { + id: 'profile.read', + protocol: 'http', + intent: 'read', + response: { + schema: { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + }, + }, + }, + }, + }, + }, + }, + generated: { + id: 'generated', + kind: 'api', + origin: 'generated', + protocol: 'rest', + generatedApiId: 'generated', + adapter: { id: 'primary-db', kind: 'database' }, + endpoints: { + items: { + id: 'items', + kind: 'database', + operations: { + 'items.create': { + id: 'items.create', + protocol: 'database', + intent: 'create', + request: { + schema: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' }, count: { type: 'integer' } }, + }, + }, + response: { + schema: { + type: 'object', + properties: { id: { type: 'string' }, name: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, +}; + +describe('binding authoring metadata', () => { + test('derives only explicitly bindable props and events from ZORA metadata', () => { + expect(resolveStudioBindableProps(button, ZORA_BINDABLE_COMPONENT_META).map((entry) => entry.name)).toEqual([ + 'children', + 'disabled', + ]); + expect(resolveStudioBindableEvents(button, ZORA_BINDABLE_COMPONENT_META).map((entry) => entry.name)).toEqual([ + 'press', + ]); + }); +}); + +describe('binding registry mutations', () => { + test('round-trips property and event bindings through the canonical registry', () => { + const prop: PropBinding = { source: { kind: 'state', path: 'draft.name' } }; + const withProp = upsertStudioPropBinding({}, button, 'children', prop); + const withEvent = appendStudioEventBinding(withProp, button, 'press', { + target: { kind: 'action', type: 'navigate' }, + input: { route: { kind: 'literal', value: '/done' } }, + }); + const serialized = JSON.parse(JSON.stringify(withEvent)) as ComponentDataBindingRegistry; + + expect(serialized[button.id]?.props?.children).toEqual(prop); + expect(serialized[button.id]?.events?.press?.[0]?.target).toEqual({ + kind: 'action', + type: 'navigate', + }); + expect(removeStudioEventBinding(removeStudioPropBinding(serialized, button, 'children'), button, 'press', 0)).toEqual({}); + }); +}); + +describe('binding operations and schemas', () => { + test('enumerates external and generated operations through one canonical model', () => { + const options = collectStudioBindingOperationOptions(dataSources); + expect(options.map((option) => option.operation.operationId)).toEqual([ + 'items.create', + 'profile.read', + ]); + expect(options[0]?.inputFields).toMatchObject([ + { name: 'name', required: true, value: { type: 'string' } }, + { name: 'count', required: false, value: { type: 'number' } }, + ]); + expect(options[1]?.responsePaths.map((entry) => entry.path)).toEqual(['', 'name', 'age']); + }); + + test('reports meaningful schema compatibility', () => { + expect(assessStudioBindingCompatibility({ type: 'string' }, { type: 'string' })).toBe('compatible'); + expect(assessStudioBindingCompatibility({ type: 'number' }, { type: 'string' })).toBe('incompatible'); + expect(assessStudioBindingCompatibility({ type: 'record' }, { type: 'object' })).toBe('compatible'); + }); +}); + +describe('binding diagnostics', () => { + test('diagnoses missing operations and incompatible response paths', () => { + const operations = collectStudioBindingOperationOptions(dataSources); + const missingRegistry = upsertStudioPropBinding({}, button, 'children', { + source: { + kind: 'operation', + operation: { dataSourceId: 'missing', endpointId: 'x', operationId: 'x.read' }, + }, + }); + const incompatibleRegistry = upsertStudioPropBinding({}, button, 'children', { + source: { + kind: 'operation', + operation: { dataSourceId: 'external', endpointId: 'profile', operationId: 'profile.read' }, + path: 'age', + }, + }); + + expect( + diagnoseStudioComponentBindings({ + node: button, + registry: missingRegistry, + componentMeta: ZORA_BINDABLE_COMPONENT_META, + dataSources, + operations, + actionTypes: ['navigate'], + })[0]?.code, + ).toBe('missing-operation'); + expect( + diagnoseStudioComponentBindings({ + node: button, + registry: incompatibleRegistry, + componentMeta: ZORA_BINDABLE_COMPONENT_META, + dataSources, + operations, + actionTypes: ['navigate'], + })[0]?.code, + ).toBe('incompatible-response'); + }); +}); From b0c5d4bcf5eaa13456c77bae93770b81ac9dfad0 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:16:46 +0200 Subject: [PATCH 10/58] ci: validate ADM 6 model branch --- .github/workflows/validate-adm6.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/validate-adm6.yml diff --git a/.github/workflows/validate-adm6.yml b/.github/workflows/validate-adm6.yml new file mode 100644 index 0000000..8da5b84 --- /dev/null +++ b/.github/workflows/validate-adm6.yml @@ -0,0 +1,25 @@ +name: Validate ADM 6 + +on: + push: + branches: + - agent/adm-6-binding-authoring + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + - run: bun install --frozen-lockfile + - run: bun run build + - run: bun run lint + - run: bun run format:check + - run: bun run knip + - run: bun run test + - run: bun run typecheck From c5dcb03a8e0c231aa849bd8e32d3908ad0817f80 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:18:50 +0200 Subject: [PATCH 11/58] fix: narrow binding schema types safely --- src/bindingSchemaModel.ts | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/bindingSchemaModel.ts b/src/bindingSchemaModel.ts index b29903f..4fe14bb 100644 --- a/src/bindingSchemaModel.ts +++ b/src/bindingSchemaModel.ts @@ -1,11 +1,15 @@ import type { DataSchema, + DataSchemaPrimitiveType, DataSchemaRegistry, UiBindableValueFieldMeta, UiBindableValueMeta, } from '@ankhorage/contracts'; -import type { StudioBindingCompatibility, StudioBindingResponsePathOption } from './bindingAuthoringContracts'; +import type { + StudioBindingCompatibility, + StudioBindingResponsePathOption, +} from './bindingAuthoringContracts'; export function resolveStudioSchemaValueMeta( schema: DataSchema | undefined, @@ -17,7 +21,9 @@ export function resolveStudioSchemaValueMeta( const type = resolveSchemaType(resolved); const fields = resolveSchemaFields(resolved, schemas, seen); const itemType = - type === 'array' ? resolveStudioSchemaValueMeta(resolved.items, schemas, seen).type : undefined; + type === 'array' + ? resolveStudioSchemaValueMeta(resolved.items, schemas, seen).type + : undefined; return { type, @@ -62,7 +68,11 @@ function collectObjectPaths( if (!resolved?.properties) return; for (const [name, property] of Object.entries(resolved.properties)) { const path = prefix ? `${prefix}.${name}` : name; - paths.push({ path, label: path, value: resolveStudioSchemaValueMeta(property, schemas, seen) }); + paths.push({ + path, + label: path, + value: resolveStudioSchemaValueMeta(property, schemas, seen), + }); collectObjectPaths(property, schemas, path, paths, new Set(seen)); } } @@ -91,10 +101,15 @@ function resolveSchemaFields( function resolveSchemaType(schema: DataSchema): UiBindableValueMeta['type'] { if (schema.format === 'date' || schema.format === 'date-time') return 'date'; - const rawType = Array.isArray(schema.type) ? (schema.type.length === 1 ? schema.type[0] : undefined) : schema.type; + const rawType = resolveSingleSchemaType(schema.type); if (rawType === 'integer') return 'number'; if (rawType === 'object') return schema.additionalProperties ? 'record' : 'object'; - if (rawType === 'array' || rawType === 'boolean' || rawType === 'number' || rawType === 'string') { + if ( + rawType === 'array' || + rawType === 'boolean' || + rawType === 'number' || + rawType === 'string' + ) { return rawType; } if (!rawType && schema.properties) return 'object'; @@ -102,10 +117,20 @@ function resolveSchemaType(schema: DataSchema): UiBindableValueMeta['type'] { return 'unknown'; } +function resolveSingleSchemaType( + type: DataSchema['type'], +): DataSchemaPrimitiveType | undefined { + if (typeof type === 'string') return type; + return type?.length === 1 ? type[0] : undefined; +} + function isObjectLike(type: UiBindableValueMeta['type']): boolean { return type === 'object' || type === 'record'; } function isImageAssetShape(value: UiBindableValueMeta): boolean { - return isObjectLike(value.type) && value.fields?.some((field) => field.path === 'uri' && field.type === 'string') === true; + return ( + isObjectLike(value.type) && + value.fields?.some((field) => field.path === 'uri' && field.type === 'string') === true + ); } From e08326395218779305c3456db51a1912afb3bfdd Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:19:44 +0200 Subject: [PATCH 12/58] ci: format ADM 6 model sources --- .github/workflows/format-adm6.yml | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/format-adm6.yml diff --git a/.github/workflows/format-adm6.yml b/.github/workflows/format-adm6.yml new file mode 100644 index 0000000..0d878e4 --- /dev/null +++ b/.github/workflows/format-adm6.yml @@ -0,0 +1,33 @@ +name: Format ADM 6 + +on: + push: + branches: + - agent/adm-6-binding-authoring + +permissions: + contents: write + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + - run: bun install --frozen-lockfile + - run: bun run lint:fix + - run: bun run format + - name: Remove temporary formatter + run: rm .github/workflows/format-adm6.yml + - name: Commit formatted model + run: | + git config user.name "Fabio Gartenmann" + git config user.email "137318798+artiphishle@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then exit 0; fi + git commit -m "style: format ADM 6 binding model" + git push origin HEAD:agent/adm-6-binding-authoring From ae837165496b7e8f3741d3845f32d793f1008611 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:20:27 +0000 Subject: [PATCH 13/58] style: format ADM 6 binding model --- .github/workflows/format-adm6.yml | 33 ---------- src/bindingAuthoringModel.test.ts | 34 ++++++---- src/bindingDiagnosticsModel.ts | 104 +++++++++++++++++++++++++----- src/bindingMetadataModel.ts | 5 +- src/bindingMutationModel.ts | 4 +- src/bindingOperationModel.ts | 12 +++- src/bindingSchemaModel.ts | 8 +-- 7 files changed, 129 insertions(+), 71 deletions(-) delete mode 100644 .github/workflows/format-adm6.yml diff --git a/.github/workflows/format-adm6.yml b/.github/workflows/format-adm6.yml deleted file mode 100644 index 0d878e4..0000000 --- a/.github/workflows/format-adm6.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Format ADM 6 - -on: - push: - branches: - - agent/adm-6-binding-authoring - -permissions: - contents: write - -jobs: - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.3.13' - - run: bun install --frozen-lockfile - - run: bun run lint:fix - - run: bun run format - - name: Remove temporary formatter - run: rm .github/workflows/format-adm6.yml - - name: Commit formatted model - run: | - git config user.name "Fabio Gartenmann" - git config user.email "137318798+artiphishle@users.noreply.github.com" - git add -A - if git diff --cached --quiet; then exit 0; fi - git commit -m "style: format ADM 6 binding model" - git push origin HEAD:agent/adm-6-binding-authoring diff --git a/src/bindingAuthoringModel.test.ts b/src/bindingAuthoringModel.test.ts index a152e68..80e933e 100644 --- a/src/bindingAuthoringModel.test.ts +++ b/src/bindingAuthoringModel.test.ts @@ -86,13 +86,12 @@ const dataSources: DataSourceRegistry = { describe('binding authoring metadata', () => { test('derives only explicitly bindable props and events from ZORA metadata', () => { - expect(resolveStudioBindableProps(button, ZORA_BINDABLE_COMPONENT_META).map((entry) => entry.name)).toEqual([ - 'children', - 'disabled', - ]); - expect(resolveStudioBindableEvents(button, ZORA_BINDABLE_COMPONENT_META).map((entry) => entry.name)).toEqual([ - 'press', - ]); + expect( + resolveStudioBindableProps(button, ZORA_BINDABLE_COMPONENT_META).map((entry) => entry.name), + ).toEqual(['children', 'disabled']); + expect( + resolveStudioBindableEvents(button, ZORA_BINDABLE_COMPONENT_META).map((entry) => entry.name), + ).toEqual(['press']); }); }); @@ -111,7 +110,14 @@ describe('binding registry mutations', () => { kind: 'action', type: 'navigate', }); - expect(removeStudioEventBinding(removeStudioPropBinding(serialized, button, 'children'), button, 'press', 0)).toEqual({}); + expect( + removeStudioEventBinding( + removeStudioPropBinding(serialized, button, 'children'), + button, + 'press', + 0, + ), + ).toEqual({}); }); }); @@ -130,9 +136,15 @@ describe('binding operations and schemas', () => { }); test('reports meaningful schema compatibility', () => { - expect(assessStudioBindingCompatibility({ type: 'string' }, { type: 'string' })).toBe('compatible'); - expect(assessStudioBindingCompatibility({ type: 'number' }, { type: 'string' })).toBe('incompatible'); - expect(assessStudioBindingCompatibility({ type: 'record' }, { type: 'object' })).toBe('compatible'); + expect(assessStudioBindingCompatibility({ type: 'string' }, { type: 'string' })).toBe( + 'compatible', + ); + expect(assessStudioBindingCompatibility({ type: 'number' }, { type: 'string' })).toBe( + 'incompatible', + ); + expect(assessStudioBindingCompatibility({ type: 'record' }, { type: 'object' })).toBe( + 'compatible', + ); }); }); diff --git a/src/bindingDiagnosticsModel.ts b/src/bindingDiagnosticsModel.ts index 032f618..d2cafaa 100644 --- a/src/bindingDiagnosticsModel.ts +++ b/src/bindingDiagnosticsModel.ts @@ -29,7 +29,9 @@ export function diagnoseStudioComponentBindings(args: { if (!binding) return []; const meta = args.componentMeta[args.node.type]?.bindings; if (!meta) { - return [diagnostic('missing-binding-meta', 'This component exposes no canonical binding metadata.')]; + return [ + diagnostic('missing-binding-meta', 'This component exposes no canonical binding metadata.'), + ]; } return [ @@ -37,7 +39,9 @@ export function diagnoseStudioComponentBindings(args: { diagnosePropBinding(name, prop, meta.props?.[name], args), ), ...Object.entries(binding.events ?? {}).flatMap(([name, events]) => - events.flatMap((event, index) => diagnoseEventBinding(name, index, event, meta.events?.[name], args)), + events.flatMap((event, index) => + diagnoseEventBinding(name, index, event, meta.events?.[name], args), + ), ), ]; } @@ -49,14 +53,22 @@ function diagnosePropBinding( args: Parameters[0], ): readonly StudioBindingDiagnostic[] { const propMeta = args.componentMeta[args.node.type]?.bindings?.props?.[name]; - if (!propMeta) return [diagnostic('unknown-prop', `Property '${name}' is not bindable.`, `props.${name}`)]; + if (!propMeta) + return [diagnostic('unknown-prop', `Property '${name}' is not bindable.`, `props.${name}`)]; if (binding.source.kind !== 'operation') return []; - const runtimeDiagnostics = validateRuntimeBindingOperationRef(binding.source.operation, args.dataSources); + const runtimeDiagnostics = validateRuntimeBindingOperationRef( + binding.source.operation, + args.dataSources, + ); if (runtimeDiagnostics.length > 0) { return [ { - ...diagnostic('missing-operation', runtimeDiagnostics[0]?.message ?? 'Operation is unavailable.', `props.${name}`), + ...diagnostic( + 'missing-operation', + runtimeDiagnostics[0]?.message ?? 'Operation is unavailable.', + `props.${name}`, + ), runtimeDiagnostics, }, ]; @@ -66,12 +78,24 @@ function diagnosePropBinding( const path = binding.source.path ?? ''; const response = operation?.responsePaths.find((candidate) => candidate.path === path); if (!response) { - return [diagnostic('missing-response-path', `Response path '${path || ''}' is unavailable.`, `props.${name}`)]; + return [ + diagnostic( + 'missing-response-path', + `Response path '${path || ''}' is unavailable.`, + `props.${name}`, + ), + ]; } const compatibility = assessStudioBindingCompatibility(propMeta.value, response.value); return compatibility === 'incompatible' - ? [diagnostic('incompatible-response', `Response '${response.label}' is incompatible with ${propMeta.value.type}.`, `props.${name}`)] + ? [ + diagnostic( + 'incompatible-response', + `Response '${response.label}' is incompatible with ${propMeta.value.type}.`, + `props.${name}`, + ), + ] : []; } @@ -84,22 +108,42 @@ function diagnoseEventBinding( ): readonly StudioBindingDiagnostic[] { const eventMeta = args.componentMeta[args.node.type]?.bindings?.events?.[eventName]; const path = `events.${eventName}.${index}`; - if (!eventMeta) return [diagnostic('unknown-event', `Event '${eventName}' is not bindable.`, path)]; + if (!eventMeta) + return [diagnostic('unknown-event', `Event '${eventName}' is not bindable.`, path)]; if (binding.target.kind === 'action') { if (!args.actionTypes.includes(binding.target.type)) { - return [diagnostic('missing-action', `Action '${binding.target.type}' is unavailable.`, path)]; + return [ + diagnostic('missing-action', `Action '${binding.target.type}' is unavailable.`, path), + ]; } return []; } - const runtimeDiagnostics = validateRuntimeBindingOperationRef(binding.target.operation, args.dataSources); + const runtimeDiagnostics = validateRuntimeBindingOperationRef( + binding.target.operation, + args.dataSources, + ); if (runtimeDiagnostics.length > 0) { - return [{ ...diagnostic('missing-operation', runtimeDiagnostics[0]?.message ?? 'Operation is unavailable.', path), runtimeDiagnostics }]; + return [ + { + ...diagnostic( + 'missing-operation', + runtimeDiagnostics[0]?.message ?? 'Operation is unavailable.', + path, + ), + runtimeDiagnostics, + }, + ]; } const operation = findStudioBindingOperationOption(args.operations, binding.target.operation); - return diagnoseEventInputs(binding, operation?.inputFields ?? [], eventMeta.payload?.fields ?? [], path); + return diagnoseEventInputs( + binding, + operation?.inputFields ?? [], + eventMeta.payload?.fields ?? [], + path, + ); } function diagnoseEventInputs( @@ -111,7 +155,15 @@ function diagnoseEventInputs( return fields.flatMap((field) => { const input = binding.input?.[field.name]; if (!input) { - return field.required ? [diagnostic('missing-input', `Required input '${field.name}' is not mapped.`, `${path}.input.${field.name}`)] : []; + return field.required + ? [ + diagnostic( + 'missing-input', + `Required input '${field.name}' is not mapped.`, + `${path}.input.${field.name}`, + ), + ] + : []; } return diagnoseInputCompatibility(input, field, eventFields, `${path}.input.${field.name}`); }); @@ -125,15 +177,33 @@ function diagnoseInputCompatibility( ): readonly StudioBindingDiagnostic[] { if (input.kind !== 'source' || input.source.kind !== 'event') return []; const eventField = eventFields.find((candidate) => candidate.path === input.source.path); - if (!eventField) return [diagnostic('incompatible-input', `Event path '${input.source.path}' is unavailable.`, path)]; - const compatibility = assessStudioBindingCompatibility(field.value, { type: toBindableType(eventField.type) }); + if (!eventField) + return [ + diagnostic('incompatible-input', `Event path '${input.source.path}' is unavailable.`, path), + ]; + const compatibility = assessStudioBindingCompatibility(field.value, { + type: toBindableType(eventField.type), + }); return compatibility === 'incompatible' - ? [diagnostic('incompatible-input', `Event field '${eventField.path}' is incompatible with ${field.value.type}.`, path)] + ? [ + diagnostic( + 'incompatible-input', + `Event field '${eventField.path}' is incompatible with ${field.value.type}.`, + path, + ), + ] : []; } function toBindableType(type: string) { - if (type === 'string' || type === 'number' || type === 'boolean' || type === 'object' || type === 'record') return type; + if ( + type === 'string' || + type === 'number' || + type === 'boolean' || + type === 'object' || + type === 'record' + ) + return type; return 'unknown' as const; } diff --git a/src/bindingMetadataModel.ts b/src/bindingMetadataModel.ts index ea60c3f..e6e9f8f 100644 --- a/src/bindingMetadataModel.ts +++ b/src/bindingMetadataModel.ts @@ -1,6 +1,9 @@ import type { UiComponentMetaRegistry, UiNode } from '@ankhorage/contracts'; -import type { StudioBindableEventOption, StudioBindablePropOption } from './bindingAuthoringContracts'; +import type { + StudioBindableEventOption, + StudioBindablePropOption, +} from './bindingAuthoringContracts'; export function resolveStudioBindableProps( node: UiNode, diff --git a/src/bindingMutationModel.ts b/src/bindingMutationModel.ts index 7978f71..7f2e004 100644 --- a/src/bindingMutationModel.ts +++ b/src/bindingMutationModel.ts @@ -80,5 +80,7 @@ function writeBinding( } function isEmptyBinding(binding: ComponentDataBinding): boolean { - return Object.keys(binding.props ?? {}).length === 0 && Object.keys(binding.events ?? {}).length === 0; + return ( + Object.keys(binding.props ?? {}).length === 0 && Object.keys(binding.events ?? {}).length === 0 + ); } diff --git a/src/bindingOperationModel.ts b/src/bindingOperationModel.ts index f8e9678..b88ffa4 100644 --- a/src/bindingOperationModel.ts +++ b/src/bindingOperationModel.ts @@ -93,7 +93,14 @@ function describeSource(source: DataSourceConfig): string { } export function createStudioActionInputFields( - payloadSchema: Readonly> | undefined, + payloadSchema: + | Readonly< + Record< + string, + { readonly label: string; readonly type: string; readonly required?: boolean } + > + > + | undefined, ): readonly StudioBindingInputFieldOption[] { return Object.entries(payloadSchema ?? {}).map(([name, field]) => ({ name, @@ -104,6 +111,7 @@ export function createStudioActionInputFields( } function toBindableType(type: string): UiBindableValueMeta['type'] { - if (type === 'string' || type === 'number' || type === 'boolean' || type === 'object') return type; + if (type === 'string' || type === 'number' || type === 'boolean' || type === 'object') + return type; return 'unknown'; } diff --git a/src/bindingSchemaModel.ts b/src/bindingSchemaModel.ts index 4fe14bb..22fb96d 100644 --- a/src/bindingSchemaModel.ts +++ b/src/bindingSchemaModel.ts @@ -21,9 +21,7 @@ export function resolveStudioSchemaValueMeta( const type = resolveSchemaType(resolved); const fields = resolveSchemaFields(resolved, schemas, seen); const itemType = - type === 'array' - ? resolveStudioSchemaValueMeta(resolved.items, schemas, seen).type - : undefined; + type === 'array' ? resolveStudioSchemaValueMeta(resolved.items, schemas, seen).type : undefined; return { type, @@ -117,9 +115,7 @@ function resolveSchemaType(schema: DataSchema): UiBindableValueMeta['type'] { return 'unknown'; } -function resolveSingleSchemaType( - type: DataSchema['type'], -): DataSchemaPrimitiveType | undefined { +function resolveSingleSchemaType(type: DataSchema['type']): DataSchemaPrimitiveType | undefined { if (typeof type === 'string') return type; return type?.length === 1 ? type[0] : undefined; } From 31f08b25d42c62d4adbdc2b827de36c5d3bf3433 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:24:11 +0200 Subject: [PATCH 14/58] feat: add binding editor draft helpers --- .../pages/bindings/bindingEditorModel.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/ui/admin/pages/bindings/bindingEditorModel.ts diff --git a/src/ui/admin/pages/bindings/bindingEditorModel.ts b/src/ui/admin/pages/bindings/bindingEditorModel.ts new file mode 100644 index 0000000..05aa80e --- /dev/null +++ b/src/ui/admin/pages/bindings/bindingEditorModel.ts @@ -0,0 +1,151 @@ +import type { + BindingInputMap, + BindingValue, + EventBinding, + PropBinding, + UiBindableValueMeta, + UiComponentEventPayloadFieldMeta, +} from '@ankhorage/contracts'; + +import type { + StudioBindingInputFieldOption, + StudioBindingOperationOption, +} from '../../../../bindingAuthoringModel'; + +export type StudioBindingSourceKind = PropBinding['source']['kind']; +export type StudioEventInputSourceKind = 'event' | 'literal'; + +export const STUDIO_BINDING_SOURCE_OPTIONS: readonly { + value: StudioBindingSourceKind; + label: string; +}[] = [ + { value: 'literal', label: 'Literal' }, + { value: 'state', label: 'State' }, + { value: 'context', label: 'Context' }, + { value: 'operation', label: 'Operation result' }, +]; + +export function createStudioPropBindingForSource( + kind: StudioBindingSourceKind, + value: UiBindableValueMeta, + operations: readonly StudioBindingOperationOption[], +): PropBinding { + if (kind === 'literal') return { source: { kind, value: createDefaultBindingValue(value) } }; + if (kind === 'state' || kind === 'context') return { source: { kind, path: '' } }; + if (kind === 'event') return { source: { kind, path: '' } }; + + const operation = operations[0]; + return operation + ? { source: { kind, operation: operation.operation, path: operation.responsePaths[0]?.path } } + : { source: { kind: 'context', path: '' } }; +} + +export function createStudioEventBinding(args: { + readonly target: + | { readonly kind: 'action'; readonly type: string } + | { readonly kind: 'operation'; readonly operation: StudioBindingOperationOption['operation'] }; + readonly fields: readonly StudioBindingInputFieldOption[]; + readonly drafts: Readonly>; +}): EventBinding { + const input = createStudioEventInputMap(args.fields, args.drafts); + return { target: args.target, ...(Object.keys(input).length > 0 ? { input } : {}) }; +} + +export interface StudioEventInputDraft { + readonly kind: StudioEventInputSourceKind; + readonly value: string; +} + +export function createStudioEventInputDrafts( + fields: readonly StudioBindingInputFieldOption[], + eventFields: readonly UiComponentEventPayloadFieldMeta[], +): Readonly> { + return Object.fromEntries( + fields.map((field) => { + const matchingEventField = eventFields.find((candidate) => candidate.path === field.name); + return [ + field.name, + matchingEventField + ? { kind: 'event', value: matchingEventField.path } + : { kind: 'literal', value: '' }, + ]; + }), + ); +} + +export function parseStudioBindingLiteral( + input: string, + meta: UiBindableValueMeta, +): BindingValue { + if (meta.type === 'boolean') return input === 'true'; + if (meta.type === 'number') { + const number = Number(input); + return Number.isFinite(number) ? number : 0; + } + if (meta.type === 'array') return parseStructuredValue(input, []); + if (meta.type === 'object' || meta.type === 'record' || meta.type === 'imageAsset') { + return parseStructuredValue(input, {}); + } + return input; +} + +export function formatStudioBindingLiteral(value: BindingValue): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +export function createStudioOperationKey(option: StudioBindingOperationOption): string { + const { dataSourceId, endpointId, operationId } = option.operation; + return `${dataSourceId}::${endpointId ?? ''}::${operationId}`; +} + +export function findStudioOperationByKey( + operations: readonly StudioBindingOperationOption[], + key: string, +): StudioBindingOperationOption | undefined { + return operations.find((option) => createStudioOperationKey(option) === key); +} + +function createStudioEventInputMap( + fields: readonly StudioBindingInputFieldOption[], + drafts: Readonly>, +): BindingInputMap { + return Object.fromEntries( + fields.flatMap((field) => { + const draft = drafts[field.name]; + if (!draft || (!draft.value && !field.required)) return []; + return [ + [ + field.name, + draft.kind === 'event' + ? { kind: 'source' as const, source: { kind: 'event' as const, path: draft.value } } + : { kind: 'literal' as const, value: parseStudioBindingLiteral(draft.value, field.value) }, + ], + ]; + }), + ); +} + +function createDefaultBindingValue(meta: UiBindableValueMeta): BindingValue { + if (meta.type === 'boolean') return false; + if (meta.type === 'number') return 0; + if (meta.type === 'array') return []; + if (meta.type === 'object' || meta.type === 'record' || meta.type === 'imageAsset') return {}; + return ''; +} + +function parseStructuredValue(input: string, fallback: BindingValue): BindingValue { + try { + const value: unknown = JSON.parse(input); + return isBindingValue(value) ? value : fallback; + } catch { + return fallback; + } +} + +function isBindingValue(value: unknown): value is BindingValue { + if (value === null) return true; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return true; + if (Array.isArray(value)) return value.every(isBindingValue); + if (typeof value !== 'object') return false; + return Object.values(value as Record).every(isBindingValue); +} From 04b290b1cf156c05dbfe1de0d510c7cc36d1f273 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:24:23 +0200 Subject: [PATCH 15/58] feat: add binding admin styles --- .../pages/bindings/bindingAdminStyles.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/ui/admin/pages/bindings/bindingAdminStyles.ts diff --git a/src/ui/admin/pages/bindings/bindingAdminStyles.ts b/src/ui/admin/pages/bindings/bindingAdminStyles.ts new file mode 100644 index 0000000..75df2f1 --- /dev/null +++ b/src/ui/admin/pages/bindings/bindingAdminStyles.ts @@ -0,0 +1,29 @@ +import { StyleSheet } from 'react-native'; + +export const bindingAdminStyles = StyleSheet.create({ + stack: { + gap: 12, + }, + compactStack: { + gap: 8, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + alignItems: 'flex-end', + }, + grow: { + flexGrow: 1, + flexBasis: 220, + }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + divider: { + height: 1, + backgroundColor: 'rgba(127, 127, 127, 0.2)', + }, +}); From 889aaf5342706ca1fd6c54bbdbff6f79fa62fc78 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:24:46 +0200 Subject: [PATCH 16/58] feat: add property binding editor --- .../pages/bindings/PropertyBindingEditor.tsx | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/ui/admin/pages/bindings/PropertyBindingEditor.tsx diff --git a/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx b/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx new file mode 100644 index 0000000..4954c54 --- /dev/null +++ b/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx @@ -0,0 +1,175 @@ +import type { PropBinding } from '@ankhorage/contracts'; +import { Button, Input, Select, Text } from '@ankhorage/zora'; +import { View } from 'react-native'; + +import { + assessStudioBindingCompatibility, + type StudioBindablePropOption, + type StudioBindingOperationOption, +} from '../../../../bindingAuthoringModel'; +import { Field } from '../../adminPagePrimitives'; +import { bindingAdminStyles } from './bindingAdminStyles'; +import { + createStudioOperationKey, + createStudioPropBindingForSource, + findStudioOperationByKey, + formatStudioBindingLiteral, + parseStudioBindingLiteral, + STUDIO_BINDING_SOURCE_OPTIONS, + type StudioBindingSourceKind, +} from './bindingEditorModel'; + +export function PropertyBindingEditor(props: { + readonly option: StudioBindablePropOption; + readonly binding: PropBinding | undefined; + readonly operations: readonly StudioBindingOperationOption[]; + readonly onChange: (binding: PropBinding) => void; + readonly onRemove: () => void; +}) { + const { binding, onChange, onRemove, operationOptions, option, operations } = { + ...props, + operationOptions: props.operations.map((operation) => ({ + value: createStudioOperationKey(operation), + label: operation.label, + })), + }; + const sourceKind = binding?.source.kind ?? 'literal'; + + return ( + + + + + + onChange({ ...binding, source: { kind: 'literal', value: value === 'true' } }) + } + /> + ); + } + return ( + + onChange({ ...binding, source: { kind: 'literal', value: parseStudioBindingLiteral(value, expected) } }) + } + /> + ); + } + + if (source.kind === 'state' || source.kind === 'context') { + return ( + onChange({ ...binding, source: { ...source, path } })} + /> + ); + } + + if (source.kind === 'event') { + return Event sources are not available for persistent property bindings.; + } + + const operationKey = createStudioOperationKey({ + operation: source.operation, + label: '', + sourceLabel: '', + inputFields: [], + responsePaths: [], + }); + const operation = findStudioOperationByKey(operations, operationKey); + const responseOptions = (operation?.responsePaths ?? []).map((response) => { + const compatibility = assessStudioBindingCompatibility(expected, response.value); + return { + value: response.path, + label: `${response.label} · ${response.value.type}${compatibility === 'incompatible' ? ' · incompatible' : ''}`, + }; + }); + + return ( + + + onChange({ ...binding, source: { ...source, path } })} + /> + + + {operation?.sourceLabel ?? 'The referenced operation is currently unavailable.'} + + + ); +} From 92285805776c2d926787a8260e7237a59d8853f0 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:25:07 +0200 Subject: [PATCH 17/58] feat: add event binding composer --- .../pages/bindings/EventBindingComposer.tsx | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/ui/admin/pages/bindings/EventBindingComposer.tsx diff --git a/src/ui/admin/pages/bindings/EventBindingComposer.tsx b/src/ui/admin/pages/bindings/EventBindingComposer.tsx new file mode 100644 index 0000000..1db1c93 --- /dev/null +++ b/src/ui/admin/pages/bindings/EventBindingComposer.tsx @@ -0,0 +1,175 @@ +import type { EventBinding, UiBindableEventMeta } from '@ankhorage/contracts'; +import { Button, Input, Select, Text } from '@ankhorage/zora'; +import { useEffect, useMemo, useState } from 'react'; +import { View } from 'react-native'; + +import { + createStudioActionInputFields, + type StudioBindingInputFieldOption, + type StudioBindingOperationOption, +} from '../../../../bindingAuthoringModel'; +import { ACTION_REGISTRY } from '../../../../index'; +import { Field } from '../../adminPagePrimitives'; +import { bindingAdminStyles } from './bindingAdminStyles'; +import { + createStudioEventBinding, + createStudioEventInputDrafts, + createStudioOperationKey, + findStudioOperationByKey, + type StudioEventInputDraft, + type StudioEventInputSourceKind, +} from './bindingEditorModel'; + +const TARGET_OPTIONS = [ + { value: 'action', label: 'Action' }, + { value: 'operation', label: 'Data-source operation' }, +] as const; +const INPUT_SOURCE_OPTIONS: readonly { value: StudioEventInputSourceKind; label: string }[] = [ + { value: 'event', label: 'Event payload' }, + { value: 'literal', label: 'Literal' }, +]; +const ACTION_OPTIONS = Object.values(ACTION_REGISTRY).map((action) => ({ + value: action.type, + label: action.label, +})); + +export function EventBindingComposer(props: { + readonly eventMeta: UiBindableEventMeta; + readonly operations: readonly StudioBindingOperationOption[]; + readonly onAdd: (binding: EventBinding) => void; +}) { + const { eventMeta, onAdd, operations } = props; + const [targetKind, setTargetKind] = useState<'action' | 'operation'>('action'); + const [actionType, setActionType] = useState(ACTION_OPTIONS[0]?.value ?? 'navigate'); + const [operationKey, setOperationKey] = useState( + operations[0] ? createStudioOperationKey(operations[0]) : '', + ); + const selectedOperation = findStudioOperationByKey(operations, operationKey); + const fields = useMemo( + () => + targetKind === 'action' + ? createStudioActionInputFields(ACTION_REGISTRY[actionType]?.payloadSchema) + : (selectedOperation?.inputFields ?? []), + [actionType, selectedOperation?.inputFields, targetKind], + ); + const eventFields = eventMeta.payload?.fields ?? []; + const [drafts, setDrafts] = useState>>({}); + + useEffect(() => { + setDrafts(createStudioEventInputDrafts(fields, eventFields)); + }, [eventFields, fields]); + + const add = () => { + const target = + targetKind === 'action' + ? ({ kind: 'action', type: actionType } as const) + : selectedOperation + ? ({ kind: 'operation', operation: selectedOperation.operation } as const) + : null; + if (!target) return; + onAdd(createStudioEventBinding({ target, fields, drafts })); + }; + + return ( + + + + + + + ) : ( + + + props.onChange({ ...props.drafts, [field.name]: { ...draft, kind } }) + } + /> + + + + + {draft.kind === 'event' && props.eventFields.length > 0 ? ( + + props.onChange({ ...props.drafts, [field.name]: { ...draft, value } }) + } + /> + )} + + + + ); + })} + + ); +} From 7547f83963c76a0cbbc64dbc0a99a5ff57a7ba93 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:25:23 +0200 Subject: [PATCH 18/58] feat: add property bindings card --- .../pages/bindings/PropertyBindingsCard.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/ui/admin/pages/bindings/PropertyBindingsCard.tsx diff --git a/src/ui/admin/pages/bindings/PropertyBindingsCard.tsx b/src/ui/admin/pages/bindings/PropertyBindingsCard.tsx new file mode 100644 index 0000000..eb2fb5b --- /dev/null +++ b/src/ui/admin/pages/bindings/PropertyBindingsCard.tsx @@ -0,0 +1,57 @@ +import type { ComponentDataBindingRegistry, UiNode } from '@ankhorage/contracts'; +import { Card, Text } from '@ankhorage/zora'; +import { View } from 'react-native'; + +import { + removeStudioPropBinding, + resolveStudioBindableProps, + upsertStudioPropBinding, + type StudioBindingOperationOption, +} from '../../../../bindingAuthoringModel'; +import { ZORA_BINDABLE_COMPONENT_META } from '@ankhorage/zora'; +import { bindingAdminStyles } from './bindingAdminStyles'; +import { PropertyBindingEditor } from './PropertyBindingEditor'; + +export function PropertyBindingsCard(props: { + readonly node: UiNode; + readonly registry: ComponentDataBindingRegistry; + readonly operations: readonly StudioBindingOperationOption[]; + readonly onChange: (registry: ComponentDataBindingRegistry) => void; +}) { + const options = resolveStudioBindableProps(props.node, ZORA_BINDABLE_COMPONENT_META); + const current = props.registry[props.node.id]?.props ?? {}; + + return ( + + + + Bind only properties explicitly exposed by ZORA metadata to literals, state, context, or + canonical operation responses. + + {options.map((option) => ( + + + props.onChange( + upsertStudioPropBinding(props.registry, props.node, option.name, binding), + ) + } + onRemove={() => + props.onChange(removeStudioPropBinding(props.registry, props.node, option.name)) + } + /> + + + ))} + {options.length === 0 ? ( + + This component exposes no bindable properties. + + ) : null} + + + ); +} From 060f16c0947f51a97ce50e9914a9bb6e42411eae Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:25:41 +0200 Subject: [PATCH 19/58] feat: add event bindings card --- .../pages/bindings/EventBindingsCard.tsx | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/ui/admin/pages/bindings/EventBindingsCard.tsx diff --git a/src/ui/admin/pages/bindings/EventBindingsCard.tsx b/src/ui/admin/pages/bindings/EventBindingsCard.tsx new file mode 100644 index 0000000..8865602 --- /dev/null +++ b/src/ui/admin/pages/bindings/EventBindingsCard.tsx @@ -0,0 +1,103 @@ +import type { ComponentDataBindingRegistry, UiNode } from '@ankhorage/contracts'; +import { Button, Card, Text, ZORA_BINDABLE_COMPONENT_META } from '@ankhorage/zora'; +import { View } from 'react-native'; + +import { + appendStudioEventBinding, + removeStudioEventBinding, + resolveStudioBindableEvents, + type StudioBindingOperationOption, +} from '../../../../bindingAuthoringModel'; +import { bindingAdminStyles } from './bindingAdminStyles'; +import { EventBindingComposer } from './EventBindingComposer'; + +export function EventBindingsCard(props: { + readonly node: UiNode; + readonly registry: ComponentDataBindingRegistry; + readonly operations: readonly StudioBindingOperationOption[]; + readonly onChange: (registry: ComponentDataBindingRegistry) => void; +}) { + const options = resolveStudioBindableEvents(props.node, ZORA_BINDABLE_COMPONENT_META); + const current = props.registry[props.node.id]?.events ?? {}; + + return ( + + + + Component events come from ZORA metadata. Bind them to canonical actions or data-source + operations; Runtime remains responsible for execution. + + {options.map((option) => ( + + {option.label} + + {(current[option.name] ?? []).map((binding, index) => ( + + + {describeEventBinding(binding)} + + {describeBindingInput(binding.input)} + + + + + ))} + + props.onChange( + appendStudioEventBinding(props.registry, props.node, option.name, binding), + ) + } + /> + + + ))} + {options.length === 0 ? ( + + This component exposes no bindable events. + + ) : null} + + + ); +} + +function EventPayloadSummary(props: { + readonly fields: readonly { readonly path: string; readonly type: string; readonly label?: string }[]; +}) { + return ( + + {props.fields.length > 0 + ? `Payload: ${props.fields.map((field) => `${field.label ?? field.path} (${field.type})`).join(', ')}` + : 'Payload: none'} + + ); +} + +function describeEventBinding(binding: { + readonly target: + | { readonly kind: 'action'; readonly type: string } + | { readonly kind: 'operation'; readonly operation: { readonly dataSourceId: string; readonly endpointId?: string; readonly operationId: string } }; + readonly input?: Readonly>; +}): string { + if (binding.target.kind === 'action') return `Action · ${binding.target.type}`; + const { dataSourceId, endpointId, operationId } = binding.target.operation; + return `Operation · ${dataSourceId} · ${endpointId ?? ''} · ${operationId}`; +} + +function describeBindingInput(input: Readonly> | undefined): string { + const keys = Object.keys(input ?? {}); + return keys.length > 0 ? `Inputs: ${keys.join(', ')}` : 'No mapped inputs'; +} From 5f7a8c39916314d4150d65262d99c67becac1965 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:25:53 +0200 Subject: [PATCH 20/58] feat: add binding diagnostics card --- .../pages/bindings/BindingDiagnosticsCard.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/ui/admin/pages/bindings/BindingDiagnosticsCard.tsx diff --git a/src/ui/admin/pages/bindings/BindingDiagnosticsCard.tsx b/src/ui/admin/pages/bindings/BindingDiagnosticsCard.tsx new file mode 100644 index 0000000..2d51b9d --- /dev/null +++ b/src/ui/admin/pages/bindings/BindingDiagnosticsCard.tsx @@ -0,0 +1,34 @@ +import { Card, Text } from '@ankhorage/zora'; +import { View } from 'react-native'; + +import type { StudioBindingDiagnostic } from '../../../../bindingAuthoringModel'; +import { bindingAdminStyles } from './bindingAdminStyles'; + +export function BindingDiagnosticsCard(props: { + readonly diagnostics: readonly StudioBindingDiagnostic[]; +}) { + return ( + + + {props.diagnostics.length === 0 ? ( + + No binding diagnostics for this component. + + ) : ( + props.diagnostics.map((diagnostic, index) => ( + + + {diagnostic.message} + + {diagnostic.path ? ( + + {diagnostic.path} + + ) : null} + + )) + )} + + + ); +} From ad390bd4f73c8989d830ea0450835e433d02221d Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:26:08 +0200 Subject: [PATCH 21/58] feat: add contextual binding admin page --- src/ui/admin/pages/BindingsAdminPage.tsx | 82 ++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/ui/admin/pages/BindingsAdminPage.tsx diff --git a/src/ui/admin/pages/BindingsAdminPage.tsx b/src/ui/admin/pages/BindingsAdminPage.tsx new file mode 100644 index 0000000..ba55f18 --- /dev/null +++ b/src/ui/admin/pages/BindingsAdminPage.tsx @@ -0,0 +1,82 @@ +import { Card, Text, ZORA_BINDABLE_COMPONENT_META } from '@ankhorage/zora'; +import React from 'react'; + +import { + collectStudioBindingOperationOptions, + diagnoseStudioComponentBindings, +} from '../../../bindingAuthoringModel'; +import { useStudio } from '../../../core/StudioContext'; +import { ACTION_REGISTRY } from '../../../index'; +import { findNodeInManifest, findScreenIdForNode } from '../../../manifestState'; +import { AdminHeader, AdminScroll, KeyValue } from '../adminPagePrimitives'; +import { BindingDiagnosticsCard } from './bindings/BindingDiagnosticsCard'; +import { EventBindingsCard } from './bindings/EventBindingsCard'; +import { PropertyBindingsCard } from './bindings/PropertyBindingsCard'; + +export function BindingsAdminPage({ nodeId }: { readonly nodeId: string | null }) { + const studio = useStudio(); + const owningScreenId = + nodeId && studio.manifest ? findScreenIdForNode(studio.manifest, nodeId) : null; + const owningRoot = owningScreenId ? studio.manifest?.screens[owningScreenId]?.root : null; + const node = owningRoot && nodeId ? findNodeInManifest(owningRoot, nodeId) : null; + + React.useEffect(() => { + if (!nodeId || !node || !owningScreenId) return; + studio.setActiveScreenId(owningScreenId); + studio.selectNode(nodeId); + }, [node, nodeId, owningScreenId, studio]); + + if (!node || !studio.manifest) { + return ( + + + + + The requested node could not be resolved in the current project manifest. + + + + ); + } + + const registry = studio.manifest.dataBindings ?? {}; + const operations = collectStudioBindingOperationOptions(studio.manifest.dataSources ?? {}); + const diagnostics = diagnoseStudioComponentBindings({ + node, + registry, + componentMeta: ZORA_BINDABLE_COMPONENT_META, + dataSources: studio.manifest.dataSources ?? {}, + operations, + actionTypes: Object.keys(ACTION_REGISTRY), + }); + + return ( + + + + + + {node.alias ? : null} + + + + + + ); +} From c7a6d157632ad84b58c614bd9a3569b202f22015 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:26:57 +0200 Subject: [PATCH 22/58] chore: wire ADM 6 contextual binding surface --- .github/workflows/wire-adm6.yml | 202 ++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 .github/workflows/wire-adm6.yml diff --git a/.github/workflows/wire-adm6.yml b/.github/workflows/wire-adm6.yml new file mode 100644 index 0000000..dbc2cf0 --- /dev/null +++ b/.github/workflows/wire-adm6.yml @@ -0,0 +1,202 @@ +name: Wire ADM 6 + +on: + push: + branches: + - agent/adm-6-binding-authoring + +permissions: + contents: write + +jobs: + wire: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Wire contextual binding authoring + run: | + python - <<'PY' + from pathlib import Path + + def replace_one(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text() + if old not in text: + raise SystemExit(f"Expected text not found in {path}: {old!r}") + target.write_text(text.replace(old, new, 1)) + + replace_one( + 'src/index.ts', + "export * from './propertiesAuthoringModel';", + "export * from './bindingAuthoringModel';\nexport * from './propertiesAuthoringModel';", + ) + replace_one( + 'src/index.ts', + " | 'theme'\n | 'properties';", + " | 'theme'\n | 'bindings'\n | 'properties';", + ) + replace_one( + 'src/index.ts', + "export type StudioAdminRoutePath = StudioAdminStaticRoutePath | `/ankh/properties/${string}`;", + "export type StudioAdminRoutePath =\n | StudioAdminStaticRoutePath\n | `/ankh/bindings/${string}`\n | `/ankh/properties/${string}`;", + ) + replace_one( + 'src/index.ts', + " 'createStudioInstancePropertyPatch',\n 'ProjectAuthHealth',", + " 'createStudioInstancePropertyPatch',\n 'resolveStudioBindableProps',\n 'resolveStudioBindableEvents',\n 'collectStudioBindingOperationOptions',\n 'ProjectAuthHealth',", + ) + + route_path = Path('src/studioAdminRouteModel.ts') + route = route_path.read_text() + route = route.replace( + " readonly propertiesNodeId: string | null;", + " readonly bindingsNodeId: string | null;\n readonly propertiesNodeId: string | null;", + 1, + ) + route = route.replace( + " readonly path: StudioAdminStaticRoutePath | '/ankh/properties/:nodeId';", + " readonly path:\n | StudioAdminStaticRoutePath\n | '/ankh/bindings/:nodeId'\n | '/ankh/properties/:nodeId';", + 1, + ) + route = route.replace( + " {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", + " {\n id: 'bindings',\n path: '/ankh/bindings/:nodeId',\n label: 'Bindings',\n icon: 'git-branch-outline',\n order: 50,\n contextual: true,\n description: 'Selected node property/data and event/action bindings.',\n },\n {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", + 1, + ) + route = route.replace(" order: 50,\n contextual: true,\n description: 'Selected node properties.',", " order: 51,\n contextual: true,\n description: 'Selected node properties.',", 1) + route = route.replace( + "const PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", + "const BINDINGS_ROUTE_PREFIX = '/ankh/bindings/';\nconst PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", + 1, + ) + route = route.replace( + "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", + "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(BINDINGS_ROUTE_PREFIX)) {\n return resolveStudioBindingsNodeId(pathname) ? 'bindings' : null;\n }\n\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", + 1, + ) + route = route.replace( + " if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", + " if (routeId === 'bindings') {\n const nodeId = resolveStudioBindingsNodeId(pathname);\n return nodeId ? createStudioBindingsRoutePath(nodeId) : null;\n }\n if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", + 1, + ) + marker = "export function resolveStudioPropertiesNodeId(pathname: string): string | null {" + binding_helpers = """export function resolveStudioBindingsNodeId(pathname: string): string | null { + return resolveStudioContextNodeId(pathname, BINDINGS_ROUTE_PREFIX); +} + +export function createStudioBindingsRoutePath(nodeId: string): `/ankh/bindings/${string}` { + return `/ankh/bindings/${encodeURIComponent(nodeId)}`; +} + +""" + route = route.replace(marker, binding_helpers + marker, 1) + route = route.replace( + "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n if (!pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {\n return null;\n }\n\n const [encodedNodeId] = pathname.slice(PROPERTIES_ROUTE_PREFIX.length).split('/');\n if (!encodedNodeId) {\n return null;\n }\n\n try {\n return decodeURIComponent(encodedNodeId);\n } catch {\n return encodedNodeId;\n }\n}", + "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n return resolveStudioContextNodeId(pathname, PROPERTIES_ROUTE_PREFIX);\n}", + 1, + ) + route = route.replace( + "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'properties') {", + "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'bindings') {\n return args.selectedNodeId ? createStudioBindingsRoutePath(args.selectedNodeId) : null;\n }\n if (args.routeId === 'properties') {", + 1, + ) + route = route.replace( + " if (routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", + " if (routeId === 'bindings' || routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", + 1, + ) + route = route.replace( + " propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", + " bindingsNodeId: resolveStudioBindingsNodeId(args.pathname),\n propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", + 1, + ) + route += """ + +function resolveStudioContextNodeId(pathname: string, prefix: string): string | null { + if (!pathname.startsWith(prefix)) return null; + const [encodedNodeId] = pathname.slice(prefix.length).split('/'); + if (!encodedNodeId) return null; + try { + return decodeURIComponent(encodedNodeId); + } catch { + return encodedNodeId; + } +} +""" + route_path.write_text(route) + + replace_one( + 'src/ui/studioAppBarModel.ts', + " readonly id: 'properties' | 'selectParent' | 'clearSelection';", + " readonly id: 'properties' | 'bindings' | 'selectParent' | 'clearSelection';", + ) + replace_one( + 'src/ui/studioAppBarModel.ts', + " const actions: StudioAppBarContextAction[] = [{ id: 'properties', label: 'Properties' }];", + " const actions: StudioAppBarContextAction[] = [\n { id: 'properties', label: 'Properties' },\n { id: 'bindings', label: 'Bindings' },\n ];", + ) + + appbar_path = Path('src/ui/useStudioAppBarAugmentation.ts') + appbar = appbar_path.read_text() + appbar = appbar.replace( + " createStudioPropertiesRoutePath,", + " createStudioBindingsRoutePath,\n createStudioPropertiesRoutePath,", + 1, + ) + appbar = appbar.replace( + " const openProperties = useCallback(() => {", + " const openBindings = useCallback(() => {\n if (!selection.selectedNodeId) return;\n router.push(createStudioBindingsRoutePath(selection.selectedNodeId));\n }, [router, selection.selectedNodeId]);\n\n const openProperties = useCallback(() => {", + 1, + ) + appbar = appbar.replace( + " action.id === 'properties'\n ? openProperties\n : action.id === 'selectParent'", + " action.id === 'properties'\n ? openProperties\n : action.id === 'bindings'\n ? openBindings\n : action.id === 'selectParent'", + 1, + ) + appbar = appbar.replace( + " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'selectParent'", + " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'bindings'\n ? { name: 'git-branch-outline' }\n : action.id === 'selectParent'", + 1, + ) + appbar_path.write_text(appbar) + + admin_path = Path('src/ui/admin/AnkhAdminPage.tsx') + admin = admin_path.read_text() + admin = admin.replace( + "import { resolveStudioPropertiesNodeId } from '../../studioAdminRouteModel';", + "import {\n resolveStudioBindingsNodeId,\n resolveStudioPropertiesNodeId,\n} from '../../studioAdminRouteModel';", + 1, + ) + admin = admin.replace( + "import { OverviewAdminPage } from './pages/OverviewAdminPage';", + "import { BindingsAdminPage } from './pages/BindingsAdminPage';\nimport { OverviewAdminPage } from './pages/OverviewAdminPage';", + 1, + ) + admin = admin.replace( + " theme: () => ,\n properties:", + " theme: () => ,\n bindings: ({ pathname }) => (\n \n ),\n properties:", + 1, + ) + admin_path.write_text(admin) + + package_path = Path('package.json') + package_text = package_path.read_text() + package_text = package_text.replace( + ' "./propertiesAuthoringModel": {', + ' "./bindingAuthoringModel": {\n "types": "./dist/bindingAuthoringModel.d.ts",\n "import": "./dist/bindingAuthoringModel.js"\n },\n "./propertiesAuthoringModel": {', + 1, + ) + package_path.write_text(package_text) + + Path('.github/workflows/validate-adm6.yml').unlink(missing_ok=True) + Path('.github/workflows/wire-adm6.yml').unlink(missing_ok=True) + PY + - name: Commit route and UI wiring + run: | + git config user.name "Fabio Gartenmann" + git config user.email "137318798+artiphishle@users.noreply.github.com" + git add -A + git commit -m "feat: wire contextual binding authoring" + git push origin HEAD:agent/adm-6-binding-authoring From 2d6a966b58a6b2e14b9563eef7e087c82329b7fb Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:27:58 +0200 Subject: [PATCH 23/58] chore: add ADM 6 wiring script --- .github/scripts/wire_adm6.py | 181 +++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .github/scripts/wire_adm6.py diff --git a/.github/scripts/wire_adm6.py b/.github/scripts/wire_adm6.py new file mode 100644 index 0000000..b19d000 --- /dev/null +++ b/.github/scripts/wire_adm6.py @@ -0,0 +1,181 @@ +from pathlib import Path + + +def replace_one(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text() + if old not in text: + raise SystemExit(f"Expected text not found in {path}: {old!r}") + target.write_text(text.replace(old, new, 1)) + + +replace_one( + "src/index.ts", + "export * from './propertiesAuthoringModel';", + "export * from './bindingAuthoringModel';\nexport * from './propertiesAuthoringModel';", +) +replace_one( + "src/index.ts", + " | 'theme'\n | 'properties';", + " | 'theme'\n | 'bindings'\n | 'properties';", +) +replace_one( + "src/index.ts", + "export type StudioAdminRoutePath = StudioAdminStaticRoutePath | `/ankh/properties/${string}`;", + "export type StudioAdminRoutePath =\n | StudioAdminStaticRoutePath\n | `/ankh/bindings/${string}`\n | `/ankh/properties/${string}`;", +) +replace_one( + "src/index.ts", + " 'createStudioInstancePropertyPatch',\n 'ProjectAuthHealth',", + " 'createStudioInstancePropertyPatch',\n 'resolveStudioBindableProps',\n 'resolveStudioBindableEvents',\n 'collectStudioBindingOperationOptions',\n 'ProjectAuthHealth',", +) + +route_path = Path("src/studioAdminRouteModel.ts") +route = route_path.read_text() +route = route.replace( + " readonly propertiesNodeId: string | null;", + " readonly bindingsNodeId: string | null;\n readonly propertiesNodeId: string | null;", + 1, +) +route = route.replace( + " readonly path: StudioAdminStaticRoutePath | '/ankh/properties/:nodeId';", + " readonly path:\n | StudioAdminStaticRoutePath\n | '/ankh/bindings/:nodeId'\n | '/ankh/properties/:nodeId';", + 1, +) +route = route.replace( + " {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", + " {\n id: 'bindings',\n path: '/ankh/bindings/:nodeId',\n label: 'Bindings',\n icon: 'git-branch-outline',\n order: 50,\n contextual: true,\n description: 'Selected node property/data and event/action bindings.',\n },\n {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", + 1, +) +route = route.replace( + " order: 50,\n contextual: true,\n description: 'Selected node properties.',", + " order: 51,\n contextual: true,\n description: 'Selected node properties.',", + 1, +) +route = route.replace( + "const PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", + "const BINDINGS_ROUTE_PREFIX = '/ankh/bindings/';\nconst PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", + 1, +) +route = route.replace( + "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", + "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(BINDINGS_ROUTE_PREFIX)) {\n return resolveStudioBindingsNodeId(pathname) ? 'bindings' : null;\n }\n\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", + 1, +) +route = route.replace( + " if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", + " if (routeId === 'bindings') {\n const nodeId = resolveStudioBindingsNodeId(pathname);\n return nodeId ? createStudioBindingsRoutePath(nodeId) : null;\n }\n if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", + 1, +) +marker = "export function resolveStudioPropertiesNodeId(pathname: string): string | null {" +binding_helpers = """export function resolveStudioBindingsNodeId(pathname: string): string | null { + return resolveStudioContextNodeId(pathname, BINDINGS_ROUTE_PREFIX); +} + +export function createStudioBindingsRoutePath(nodeId: string): `/ankh/bindings/${string}` { + return `/ankh/bindings/${encodeURIComponent(nodeId)}`; +} + +""" +route = route.replace(marker, binding_helpers + marker, 1) +route = route.replace( + "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n if (!pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {\n return null;\n }\n\n const [encodedNodeId] = pathname.slice(PROPERTIES_ROUTE_PREFIX.length).split('/');\n if (!encodedNodeId) {\n return null;\n }\n\n try {\n return decodeURIComponent(encodedNodeId);\n } catch {\n return encodedNodeId;\n }\n}", + "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n return resolveStudioContextNodeId(pathname, PROPERTIES_ROUTE_PREFIX);\n}", + 1, +) +route = route.replace( + "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'properties') {", + "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'bindings') {\n return args.selectedNodeId ? createStudioBindingsRoutePath(args.selectedNodeId) : null;\n }\n if (args.routeId === 'properties') {", + 1, +) +route = route.replace( + " if (routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", + " if (routeId === 'bindings' || routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", + 1, +) +route = route.replace( + " propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", + " bindingsNodeId: resolveStudioBindingsNodeId(args.pathname),\n propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", + 1, +) +route += """ + +function resolveStudioContextNodeId(pathname: string, prefix: string): string | null { + if (!pathname.startsWith(prefix)) return null; + const [encodedNodeId] = pathname.slice(prefix.length).split('/'); + if (!encodedNodeId) return null; + try { + return decodeURIComponent(encodedNodeId); + } catch { + return encodedNodeId; + } +} +""" +route_path.write_text(route) + +replace_one( + "src/ui/studioAppBarModel.ts", + " readonly id: 'properties' | 'selectParent' | 'clearSelection';", + " readonly id: 'properties' | 'bindings' | 'selectParent' | 'clearSelection';", +) +replace_one( + "src/ui/studioAppBarModel.ts", + " const actions: StudioAppBarContextAction[] = [{ id: 'properties', label: 'Properties' }];", + " const actions: StudioAppBarContextAction[] = [\n { id: 'properties', label: 'Properties' },\n { id: 'bindings', label: 'Bindings' },\n ];", +) + +appbar_path = Path("src/ui/useStudioAppBarAugmentation.ts") +appbar = appbar_path.read_text() +appbar = appbar.replace( + " createStudioPropertiesRoutePath,", + " createStudioBindingsRoutePath,\n createStudioPropertiesRoutePath,", + 1, +) +appbar = appbar.replace( + " const openProperties = useCallback(() => {", + " const openBindings = useCallback(() => {\n if (!selection.selectedNodeId) return;\n router.push(createStudioBindingsRoutePath(selection.selectedNodeId));\n }, [router, selection.selectedNodeId]);\n\n const openProperties = useCallback(() => {", + 1, +) +appbar = appbar.replace( + " action.id === 'properties'\n ? openProperties\n : action.id === 'selectParent'", + " action.id === 'properties'\n ? openProperties\n : action.id === 'bindings'\n ? openBindings\n : action.id === 'selectParent'", + 1, +) +appbar = appbar.replace( + " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'selectParent'", + " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'bindings'\n ? { name: 'git-branch-outline' }\n : action.id === 'selectParent'", + 1, +) +appbar_path.write_text(appbar) + +admin_path = Path("src/ui/admin/AnkhAdminPage.tsx") +admin = admin_path.read_text() +admin = admin.replace( + "import { resolveStudioPropertiesNodeId } from '../../studioAdminRouteModel';", + "import {\n resolveStudioBindingsNodeId,\n resolveStudioPropertiesNodeId,\n} from '../../studioAdminRouteModel';", + 1, +) +admin = admin.replace( + "import { OverviewAdminPage } from './pages/OverviewAdminPage';", + "import { BindingsAdminPage } from './pages/BindingsAdminPage';\nimport { OverviewAdminPage } from './pages/OverviewAdminPage';", + 1, +) +admin = admin.replace( + " theme: () => ,\n properties:", + " theme: () => ,\n bindings: ({ pathname }) => (\n \n ),\n properties:", + 1, +) +admin_path.write_text(admin) + +package_path = Path("package.json") +package_text = package_path.read_text() +package_text = package_text.replace( + ' "./propertiesAuthoringModel": {', + ' "./bindingAuthoringModel": {\n "types": "./dist/bindingAuthoringModel.d.ts",\n "import": "./dist/bindingAuthoringModel.js"\n },\n "./propertiesAuthoringModel": {', + 1, +) +package_path.write_text(package_text) + +Path(".github/workflows/validate-adm6.yml").unlink(missing_ok=True) +Path(".github/workflows/wire-adm6.yml").unlink(missing_ok=True) +Path(".github/scripts/wire_adm6.py").unlink(missing_ok=True) From 21dbef8c081e4902c534ec84706733bae43b3e12 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:28:08 +0200 Subject: [PATCH 24/58] chore: simplify ADM 6 wiring workflow --- .github/workflows/wire-adm6.yml | 182 +------------------------------- 1 file changed, 3 insertions(+), 179 deletions(-) diff --git a/.github/workflows/wire-adm6.yml b/.github/workflows/wire-adm6.yml index dbc2cf0..788004b 100644 --- a/.github/workflows/wire-adm6.yml +++ b/.github/workflows/wire-adm6.yml @@ -15,185 +15,9 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Wire contextual binding authoring - run: | - python - <<'PY' - from pathlib import Path - - def replace_one(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text() - if old not in text: - raise SystemExit(f"Expected text not found in {path}: {old!r}") - target.write_text(text.replace(old, new, 1)) - - replace_one( - 'src/index.ts', - "export * from './propertiesAuthoringModel';", - "export * from './bindingAuthoringModel';\nexport * from './propertiesAuthoringModel';", - ) - replace_one( - 'src/index.ts', - " | 'theme'\n | 'properties';", - " | 'theme'\n | 'bindings'\n | 'properties';", - ) - replace_one( - 'src/index.ts', - "export type StudioAdminRoutePath = StudioAdminStaticRoutePath | `/ankh/properties/${string}`;", - "export type StudioAdminRoutePath =\n | StudioAdminStaticRoutePath\n | `/ankh/bindings/${string}`\n | `/ankh/properties/${string}`;", - ) - replace_one( - 'src/index.ts', - " 'createStudioInstancePropertyPatch',\n 'ProjectAuthHealth',", - " 'createStudioInstancePropertyPatch',\n 'resolveStudioBindableProps',\n 'resolveStudioBindableEvents',\n 'collectStudioBindingOperationOptions',\n 'ProjectAuthHealth',", - ) - - route_path = Path('src/studioAdminRouteModel.ts') - route = route_path.read_text() - route = route.replace( - " readonly propertiesNodeId: string | null;", - " readonly bindingsNodeId: string | null;\n readonly propertiesNodeId: string | null;", - 1, - ) - route = route.replace( - " readonly path: StudioAdminStaticRoutePath | '/ankh/properties/:nodeId';", - " readonly path:\n | StudioAdminStaticRoutePath\n | '/ankh/bindings/:nodeId'\n | '/ankh/properties/:nodeId';", - 1, - ) - route = route.replace( - " {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", - " {\n id: 'bindings',\n path: '/ankh/bindings/:nodeId',\n label: 'Bindings',\n icon: 'git-branch-outline',\n order: 50,\n contextual: true,\n description: 'Selected node property/data and event/action bindings.',\n },\n {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", - 1, - ) - route = route.replace(" order: 50,\n contextual: true,\n description: 'Selected node properties.',", " order: 51,\n contextual: true,\n description: 'Selected node properties.',", 1) - route = route.replace( - "const PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", - "const BINDINGS_ROUTE_PREFIX = '/ankh/bindings/';\nconst PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", - 1, - ) - route = route.replace( - "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", - "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(BINDINGS_ROUTE_PREFIX)) {\n return resolveStudioBindingsNodeId(pathname) ? 'bindings' : null;\n }\n\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", - 1, - ) - route = route.replace( - " if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", - " if (routeId === 'bindings') {\n const nodeId = resolveStudioBindingsNodeId(pathname);\n return nodeId ? createStudioBindingsRoutePath(nodeId) : null;\n }\n if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", - 1, - ) - marker = "export function resolveStudioPropertiesNodeId(pathname: string): string | null {" - binding_helpers = """export function resolveStudioBindingsNodeId(pathname: string): string | null { - return resolveStudioContextNodeId(pathname, BINDINGS_ROUTE_PREFIX); -} - -export function createStudioBindingsRoutePath(nodeId: string): `/ankh/bindings/${string}` { - return `/ankh/bindings/${encodeURIComponent(nodeId)}`; -} - -""" - route = route.replace(marker, binding_helpers + marker, 1) - route = route.replace( - "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n if (!pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {\n return null;\n }\n\n const [encodedNodeId] = pathname.slice(PROPERTIES_ROUTE_PREFIX.length).split('/');\n if (!encodedNodeId) {\n return null;\n }\n\n try {\n return decodeURIComponent(encodedNodeId);\n } catch {\n return encodedNodeId;\n }\n}", - "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n return resolveStudioContextNodeId(pathname, PROPERTIES_ROUTE_PREFIX);\n}", - 1, - ) - route = route.replace( - "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'properties') {", - "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'bindings') {\n return args.selectedNodeId ? createStudioBindingsRoutePath(args.selectedNodeId) : null;\n }\n if (args.routeId === 'properties') {", - 1, - ) - route = route.replace( - " if (routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", - " if (routeId === 'bindings' || routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", - 1, - ) - route = route.replace( - " propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", - " bindingsNodeId: resolveStudioBindingsNodeId(args.pathname),\n propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", - 1, - ) - route += """ - -function resolveStudioContextNodeId(pathname: string, prefix: string): string | null { - if (!pathname.startsWith(prefix)) return null; - const [encodedNodeId] = pathname.slice(prefix.length).split('/'); - if (!encodedNodeId) return null; - try { - return decodeURIComponent(encodedNodeId); - } catch { - return encodedNodeId; - } -} -""" - route_path.write_text(route) - - replace_one( - 'src/ui/studioAppBarModel.ts', - " readonly id: 'properties' | 'selectParent' | 'clearSelection';", - " readonly id: 'properties' | 'bindings' | 'selectParent' | 'clearSelection';", - ) - replace_one( - 'src/ui/studioAppBarModel.ts', - " const actions: StudioAppBarContextAction[] = [{ id: 'properties', label: 'Properties' }];", - " const actions: StudioAppBarContextAction[] = [\n { id: 'properties', label: 'Properties' },\n { id: 'bindings', label: 'Bindings' },\n ];", - ) - - appbar_path = Path('src/ui/useStudioAppBarAugmentation.ts') - appbar = appbar_path.read_text() - appbar = appbar.replace( - " createStudioPropertiesRoutePath,", - " createStudioBindingsRoutePath,\n createStudioPropertiesRoutePath,", - 1, - ) - appbar = appbar.replace( - " const openProperties = useCallback(() => {", - " const openBindings = useCallback(() => {\n if (!selection.selectedNodeId) return;\n router.push(createStudioBindingsRoutePath(selection.selectedNodeId));\n }, [router, selection.selectedNodeId]);\n\n const openProperties = useCallback(() => {", - 1, - ) - appbar = appbar.replace( - " action.id === 'properties'\n ? openProperties\n : action.id === 'selectParent'", - " action.id === 'properties'\n ? openProperties\n : action.id === 'bindings'\n ? openBindings\n : action.id === 'selectParent'", - 1, - ) - appbar = appbar.replace( - " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'selectParent'", - " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'bindings'\n ? { name: 'git-branch-outline' }\n : action.id === 'selectParent'", - 1, - ) - appbar_path.write_text(appbar) - - admin_path = Path('src/ui/admin/AnkhAdminPage.tsx') - admin = admin_path.read_text() - admin = admin.replace( - "import { resolveStudioPropertiesNodeId } from '../../studioAdminRouteModel';", - "import {\n resolveStudioBindingsNodeId,\n resolveStudioPropertiesNodeId,\n} from '../../studioAdminRouteModel';", - 1, - ) - admin = admin.replace( - "import { OverviewAdminPage } from './pages/OverviewAdminPage';", - "import { BindingsAdminPage } from './pages/BindingsAdminPage';\nimport { OverviewAdminPage } from './pages/OverviewAdminPage';", - 1, - ) - admin = admin.replace( - " theme: () => ,\n properties:", - " theme: () => ,\n bindings: ({ pathname }) => (\n \n ),\n properties:", - 1, - ) - admin_path.write_text(admin) - - package_path = Path('package.json') - package_text = package_path.read_text() - package_text = package_text.replace( - ' "./propertiesAuthoringModel": {', - ' "./bindingAuthoringModel": {\n "types": "./dist/bindingAuthoringModel.d.ts",\n "import": "./dist/bindingAuthoringModel.js"\n },\n "./propertiesAuthoringModel": {', - 1, - ) - package_path.write_text(package_text) - - Path('.github/workflows/validate-adm6.yml').unlink(missing_ok=True) - Path('.github/workflows/wire-adm6.yml').unlink(missing_ok=True) - PY - - name: Commit route and UI wiring + - name: Apply wiring + run: python .github/scripts/wire_adm6.py + - name: Commit wiring run: | git config user.name "Fabio Gartenmann" git config user.email "137318798+artiphishle@users.noreply.github.com" From 84a2e8f0e6f7836a86242ecd028dff3758be2290 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:28:17 +0000 Subject: [PATCH 25/58] feat: wire contextual binding authoring --- .github/scripts/wire_adm6.py | 181 -------------------------- .github/workflows/validate-adm6.yml | 25 ---- .github/workflows/wire-adm6.yml | 26 ---- package.json | 4 + src/index.ts | 10 +- src/studioAdminRouteModel.ts | 64 ++++++--- src/ui/admin/AnkhAdminPage.tsx | 9 +- src/ui/studioAppBarModel.ts | 7 +- src/ui/useStudioAppBarAugmentation.ts | 14 +- 9 files changed, 86 insertions(+), 254 deletions(-) delete mode 100644 .github/scripts/wire_adm6.py delete mode 100644 .github/workflows/validate-adm6.yml delete mode 100644 .github/workflows/wire-adm6.yml diff --git a/.github/scripts/wire_adm6.py b/.github/scripts/wire_adm6.py deleted file mode 100644 index b19d000..0000000 --- a/.github/scripts/wire_adm6.py +++ /dev/null @@ -1,181 +0,0 @@ -from pathlib import Path - - -def replace_one(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text() - if old not in text: - raise SystemExit(f"Expected text not found in {path}: {old!r}") - target.write_text(text.replace(old, new, 1)) - - -replace_one( - "src/index.ts", - "export * from './propertiesAuthoringModel';", - "export * from './bindingAuthoringModel';\nexport * from './propertiesAuthoringModel';", -) -replace_one( - "src/index.ts", - " | 'theme'\n | 'properties';", - " | 'theme'\n | 'bindings'\n | 'properties';", -) -replace_one( - "src/index.ts", - "export type StudioAdminRoutePath = StudioAdminStaticRoutePath | `/ankh/properties/${string}`;", - "export type StudioAdminRoutePath =\n | StudioAdminStaticRoutePath\n | `/ankh/bindings/${string}`\n | `/ankh/properties/${string}`;", -) -replace_one( - "src/index.ts", - " 'createStudioInstancePropertyPatch',\n 'ProjectAuthHealth',", - " 'createStudioInstancePropertyPatch',\n 'resolveStudioBindableProps',\n 'resolveStudioBindableEvents',\n 'collectStudioBindingOperationOptions',\n 'ProjectAuthHealth',", -) - -route_path = Path("src/studioAdminRouteModel.ts") -route = route_path.read_text() -route = route.replace( - " readonly propertiesNodeId: string | null;", - " readonly bindingsNodeId: string | null;\n readonly propertiesNodeId: string | null;", - 1, -) -route = route.replace( - " readonly path: StudioAdminStaticRoutePath | '/ankh/properties/:nodeId';", - " readonly path:\n | StudioAdminStaticRoutePath\n | '/ankh/bindings/:nodeId'\n | '/ankh/properties/:nodeId';", - 1, -) -route = route.replace( - " {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", - " {\n id: 'bindings',\n path: '/ankh/bindings/:nodeId',\n label: 'Bindings',\n icon: 'git-branch-outline',\n order: 50,\n contextual: true,\n description: 'Selected node property/data and event/action bindings.',\n },\n {\n id: 'properties',\n path: '/ankh/properties/:nodeId',", - 1, -) -route = route.replace( - " order: 50,\n contextual: true,\n description: 'Selected node properties.',", - " order: 51,\n contextual: true,\n description: 'Selected node properties.',", - 1, -) -route = route.replace( - "const PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", - "const BINDINGS_ROUTE_PREFIX = '/ankh/bindings/';\nconst PROPERTIES_ROUTE_PREFIX = '/ankh/properties/';", - 1, -) -route = route.replace( - "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", - "export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null {\n if (pathname.startsWith(BINDINGS_ROUTE_PREFIX)) {\n return resolveStudioBindingsNodeId(pathname) ? 'bindings' : null;\n }\n\n if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {", - 1, -) -route = route.replace( - " if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", - " if (routeId === 'bindings') {\n const nodeId = resolveStudioBindingsNodeId(pathname);\n return nodeId ? createStudioBindingsRoutePath(nodeId) : null;\n }\n if (routeId === 'properties') {\n const nodeId = resolveStudioPropertiesNodeId(pathname);\n return nodeId ? createStudioPropertiesRoutePath(nodeId) : null;\n }", - 1, -) -marker = "export function resolveStudioPropertiesNodeId(pathname: string): string | null {" -binding_helpers = """export function resolveStudioBindingsNodeId(pathname: string): string | null { - return resolveStudioContextNodeId(pathname, BINDINGS_ROUTE_PREFIX); -} - -export function createStudioBindingsRoutePath(nodeId: string): `/ankh/bindings/${string}` { - return `/ankh/bindings/${encodeURIComponent(nodeId)}`; -} - -""" -route = route.replace(marker, binding_helpers + marker, 1) -route = route.replace( - "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n if (!pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) {\n return null;\n }\n\n const [encodedNodeId] = pathname.slice(PROPERTIES_ROUTE_PREFIX.length).split('/');\n if (!encodedNodeId) {\n return null;\n }\n\n try {\n return decodeURIComponent(encodedNodeId);\n } catch {\n return encodedNodeId;\n }\n}", - "export function resolveStudioPropertiesNodeId(pathname: string): string | null {\n return resolveStudioContextNodeId(pathname, PROPERTIES_ROUTE_PREFIX);\n}", - 1, -) -route = route.replace( - "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'properties') {", - "export function createStudioAdminRoutePath(args: {\n routeId: StudioAdminRouteId;\n selectedNodeId?: string | null;\n}): StudioAdminRoutePath | null {\n if (args.routeId === 'bindings') {\n return args.selectedNodeId ? createStudioBindingsRoutePath(args.selectedNodeId) : null;\n }\n if (args.routeId === 'properties') {", - 1, -) -route = route.replace( - " if (routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", - " if (routeId === 'bindings' || routeId === 'properties') {\n return context.selectedNodeId !== null;\n }", - 1, -) -route = route.replace( - " propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", - " bindingsNodeId: resolveStudioBindingsNodeId(args.pathname),\n propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname),", - 1, -) -route += """ - -function resolveStudioContextNodeId(pathname: string, prefix: string): string | null { - if (!pathname.startsWith(prefix)) return null; - const [encodedNodeId] = pathname.slice(prefix.length).split('/'); - if (!encodedNodeId) return null; - try { - return decodeURIComponent(encodedNodeId); - } catch { - return encodedNodeId; - } -} -""" -route_path.write_text(route) - -replace_one( - "src/ui/studioAppBarModel.ts", - " readonly id: 'properties' | 'selectParent' | 'clearSelection';", - " readonly id: 'properties' | 'bindings' | 'selectParent' | 'clearSelection';", -) -replace_one( - "src/ui/studioAppBarModel.ts", - " const actions: StudioAppBarContextAction[] = [{ id: 'properties', label: 'Properties' }];", - " const actions: StudioAppBarContextAction[] = [\n { id: 'properties', label: 'Properties' },\n { id: 'bindings', label: 'Bindings' },\n ];", -) - -appbar_path = Path("src/ui/useStudioAppBarAugmentation.ts") -appbar = appbar_path.read_text() -appbar = appbar.replace( - " createStudioPropertiesRoutePath,", - " createStudioBindingsRoutePath,\n createStudioPropertiesRoutePath,", - 1, -) -appbar = appbar.replace( - " const openProperties = useCallback(() => {", - " const openBindings = useCallback(() => {\n if (!selection.selectedNodeId) return;\n router.push(createStudioBindingsRoutePath(selection.selectedNodeId));\n }, [router, selection.selectedNodeId]);\n\n const openProperties = useCallback(() => {", - 1, -) -appbar = appbar.replace( - " action.id === 'properties'\n ? openProperties\n : action.id === 'selectParent'", - " action.id === 'properties'\n ? openProperties\n : action.id === 'bindings'\n ? openBindings\n : action.id === 'selectParent'", - 1, -) -appbar = appbar.replace( - " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'selectParent'", - " action.id === 'properties'\n ? { name: 'options-outline' }\n : action.id === 'bindings'\n ? { name: 'git-branch-outline' }\n : action.id === 'selectParent'", - 1, -) -appbar_path.write_text(appbar) - -admin_path = Path("src/ui/admin/AnkhAdminPage.tsx") -admin = admin_path.read_text() -admin = admin.replace( - "import { resolveStudioPropertiesNodeId } from '../../studioAdminRouteModel';", - "import {\n resolveStudioBindingsNodeId,\n resolveStudioPropertiesNodeId,\n} from '../../studioAdminRouteModel';", - 1, -) -admin = admin.replace( - "import { OverviewAdminPage } from './pages/OverviewAdminPage';", - "import { BindingsAdminPage } from './pages/BindingsAdminPage';\nimport { OverviewAdminPage } from './pages/OverviewAdminPage';", - 1, -) -admin = admin.replace( - " theme: () => ,\n properties:", - " theme: () => ,\n bindings: ({ pathname }) => (\n \n ),\n properties:", - 1, -) -admin_path.write_text(admin) - -package_path = Path("package.json") -package_text = package_path.read_text() -package_text = package_text.replace( - ' "./propertiesAuthoringModel": {', - ' "./bindingAuthoringModel": {\n "types": "./dist/bindingAuthoringModel.d.ts",\n "import": "./dist/bindingAuthoringModel.js"\n },\n "./propertiesAuthoringModel": {', - 1, -) -package_path.write_text(package_text) - -Path(".github/workflows/validate-adm6.yml").unlink(missing_ok=True) -Path(".github/workflows/wire-adm6.yml").unlink(missing_ok=True) -Path(".github/scripts/wire_adm6.py").unlink(missing_ok=True) diff --git a/.github/workflows/validate-adm6.yml b/.github/workflows/validate-adm6.yml deleted file mode 100644 index 8da5b84..0000000 --- a/.github/workflows/validate-adm6.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Validate ADM 6 - -on: - push: - branches: - - agent/adm-6-binding-authoring - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.3.13' - - run: bun install --frozen-lockfile - - run: bun run build - - run: bun run lint - - run: bun run format:check - - run: bun run knip - - run: bun run test - - run: bun run typecheck diff --git a/.github/workflows/wire-adm6.yml b/.github/workflows/wire-adm6.yml deleted file mode 100644 index 788004b..0000000 --- a/.github/workflows/wire-adm6.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Wire ADM 6 - -on: - push: - branches: - - agent/adm-6-binding-authoring - -permissions: - contents: write - -jobs: - wire: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Apply wiring - run: python .github/scripts/wire_adm6.py - - name: Commit wiring - run: | - git config user.name "Fabio Gartenmann" - git config user.email "137318798+artiphishle@users.noreply.github.com" - git add -A - git commit -m "feat: wire contextual binding authoring" - git push origin HEAD:agent/adm-6-binding-authoring diff --git a/package.json b/package.json index ff7a2ec..553506c 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,10 @@ "types": "./dist/manifestState.d.ts", "import": "./dist/manifestState.js" }, + "./bindingAuthoringModel": { + "types": "./dist/bindingAuthoringModel.d.ts", + "import": "./dist/bindingAuthoringModel.js" + }, "./propertiesAuthoringModel": { "types": "./dist/propertiesAuthoringModel.d.ts", "import": "./dist/propertiesAuthoringModel.js" diff --git a/src/index.ts b/src/index.ts index 9b3e22a..5818182 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export type { ProjectSortKey, StudioProjectSummary, } from './projectWorkspaceContracts'; +export * from './bindingAuthoringModel'; export * from './propertiesAuthoringModel'; export type { TemplateCatalog, @@ -97,6 +98,9 @@ export const STUDIO_PUBLIC_CONTRACTS = [ 'StudioInstancePropertyField', 'resolveStudioInstancePropertyGroups', 'createStudioInstancePropertyPatch', + 'resolveStudioBindableProps', + 'resolveStudioBindableEvents', + 'collectStudioBindingOperationOptions', 'ProjectAuthHealth', 'ProjectSecretUsageSummary', 'StudioAdminRouteId', @@ -130,6 +134,7 @@ export type StudioAdminRouteId = | 'auth-profile' | 'secrets' | 'theme' + | 'bindings' | 'properties'; export type StudioAdminStaticRoutePath = | '/ankh' @@ -142,7 +147,10 @@ export type StudioAdminStaticRoutePath = | '/ankh/auth/profile' | '/ankh/secrets' | '/ankh/theme'; -export type StudioAdminRoutePath = StudioAdminStaticRoutePath | `/ankh/properties/${string}`; +export type StudioAdminRoutePath = + | StudioAdminStaticRoutePath + | `/ankh/bindings/${string}` + | `/ankh/properties/${string}`; export type StudioManifest = AppManifest & { infra: AppManifest['infra'] & { diff --git a/src/studioAdminRouteModel.ts b/src/studioAdminRouteModel.ts index 36e9d92..33947c2 100644 --- a/src/studioAdminRouteModel.ts +++ b/src/studioAdminRouteModel.ts @@ -7,7 +7,10 @@ import type { export interface StudioAdminRouteDefinition { readonly id: StudioAdminRouteId; - readonly path: StudioAdminStaticRoutePath | '/ankh/properties/:nodeId'; + readonly path: + | StudioAdminStaticRoutePath + | '/ankh/bindings/:nodeId' + | '/ankh/properties/:nodeId'; readonly label: string; readonly icon: string; readonly order: number; @@ -115,17 +118,27 @@ export const STUDIO_ADMIN_ROUTE_REGISTRY: readonly StudioAdminRouteDefinition[] order: 40, description: 'Active theme editing.', }, + { + id: 'bindings', + path: '/ankh/bindings/:nodeId', + label: 'Bindings', + icon: 'git-branch-outline', + order: 50, + contextual: true, + description: 'Selected node property/data and event/action bindings.', + }, { id: 'properties', path: '/ankh/properties/:nodeId', label: 'Properties', icon: 'options-outline', - order: 50, + order: 51, contextual: true, description: 'Selected node properties.', }, ]; +const BINDINGS_ROUTE_PREFIX = '/ankh/bindings/'; const PROPERTIES_ROUTE_PREFIX = '/ankh/properties/'; export function getStudioAdminRouteDefinition( @@ -140,6 +153,10 @@ export function getStudioAdminRouteDefinition( } export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId | null { + if (pathname.startsWith(BINDINGS_ROUTE_PREFIX)) { + return resolveStudioBindingsNodeId(pathname) ? 'bindings' : null; + } + if (pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) { return resolveStudioPropertiesNodeId(pathname) ? 'properties' : null; } @@ -154,6 +171,10 @@ export function resolveStudioAdminRouteId(pathname: string): StudioAdminRouteId export function resolveStudioAdminRoutePath(pathname: string): StudioAdminRoutePath | null { const routeId = resolveStudioAdminRouteId(pathname); if (!routeId) return null; + if (routeId === 'bindings') { + const nodeId = resolveStudioBindingsNodeId(pathname); + return nodeId ? createStudioBindingsRoutePath(nodeId) : null; + } if (routeId === 'properties') { const nodeId = resolveStudioPropertiesNodeId(pathname); return nodeId ? createStudioPropertiesRoutePath(nodeId) : null; @@ -162,21 +183,16 @@ export function resolveStudioAdminRoutePath(pathname: string): StudioAdminRouteP return getStudioAdminRouteDefinition(routeId).path; } -export function resolveStudioPropertiesNodeId(pathname: string): string | null { - if (!pathname.startsWith(PROPERTIES_ROUTE_PREFIX)) { - return null; - } +export function resolveStudioBindingsNodeId(pathname: string): string | null { + return resolveStudioContextNodeId(pathname, BINDINGS_ROUTE_PREFIX); +} - const [encodedNodeId] = pathname.slice(PROPERTIES_ROUTE_PREFIX.length).split('/'); - if (!encodedNodeId) { - return null; - } +export function createStudioBindingsRoutePath(nodeId: string): `/ankh/bindings/${string}` { + return `/ankh/bindings/${encodeURIComponent(nodeId)}`; +} - try { - return decodeURIComponent(encodedNodeId); - } catch { - return encodedNodeId; - } +export function resolveStudioPropertiesNodeId(pathname: string): string | null { + return resolveStudioContextNodeId(pathname, PROPERTIES_ROUTE_PREFIX); } export function createStudioPropertiesRoutePath(nodeId: string): `/ankh/properties/${string}` { @@ -187,6 +203,9 @@ export function createStudioAdminRoutePath(args: { routeId: StudioAdminRouteId; selectedNodeId?: string | null; }): StudioAdminRoutePath | null { + if (args.routeId === 'bindings') { + return args.selectedNodeId ? createStudioBindingsRoutePath(args.selectedNodeId) : null; + } if (args.routeId === 'properties') { return args.selectedNodeId ? createStudioPropertiesRoutePath(args.selectedNodeId) : null; } @@ -198,7 +217,7 @@ export function isStudioAdminRouteAvailable( routeId: StudioAdminRouteId, context: StudioAdminRouteAvailabilityContext, ): boolean { - if (routeId === 'properties') { + if (routeId === 'bindings' || routeId === 'properties') { return context.selectedNodeId !== null; } @@ -238,6 +257,7 @@ export function createStudioAdminRouteRenderState(args: { routeAdminId, resolvedAdminRouteId, routeAdminPath, + bindingsNodeId: resolveStudioBindingsNodeId(args.pathname), propertiesNodeId: resolveStudioPropertiesNodeId(args.pathname), shouldRenderAppContent: routeAdminId === null, shouldRenderAdminShell: routeAdminId !== null, @@ -282,3 +302,15 @@ export function resolveStudioLastNonAdminLocation(args: { if (isStudioAdminPath(args.pathname)) return null; return args.navigableLocation ?? resolveStudioNavigableLocation(args.pathname); } + + +function resolveStudioContextNodeId(pathname: string, prefix: string): string | null { + if (!pathname.startsWith(prefix)) return null; + const [encodedNodeId] = pathname.slice(prefix.length).split('/'); + if (!encodedNodeId) return null; + try { + return decodeURIComponent(encodedNodeId); + } catch { + return encodedNodeId; + } +} diff --git a/src/ui/admin/AnkhAdminPage.tsx b/src/ui/admin/AnkhAdminPage.tsx index 1cb73b1..a183f5c 100644 --- a/src/ui/admin/AnkhAdminPage.tsx +++ b/src/ui/admin/AnkhAdminPage.tsx @@ -3,9 +3,13 @@ import React from 'react'; import { useStudio } from '../../core/StudioContext'; import type { StudioAdminRouteId, StudioContextValue } from '../../index'; -import { resolveStudioPropertiesNodeId } from '../../studioAdminRouteModel'; +import { + resolveStudioBindingsNodeId, + resolveStudioPropertiesNodeId, +} from '../../studioAdminRouteModel'; import { ApisAdminPage, type ApisAdminRouteId } from './pages/ApisAdminPage'; import { AuthAdminPage, type AuthAdminPageProps } from './pages/AuthAdminPage'; +import { BindingsAdminPage } from './pages/BindingsAdminPage'; import { OverviewAdminPage } from './pages/OverviewAdminPage'; import { PropertiesAdminPage } from './pages/PropertiesAdminPage'; import { SecretsAdminPage } from './pages/SecretsAdminPage'; @@ -59,6 +63,9 @@ const ADMIN_PAGE_RENDERERS = { ), secrets: ({ studio }) => , theme: () => , + bindings: ({ pathname }) => ( + + ), properties: ({ pathname }) => ( ), diff --git a/src/ui/studioAppBarModel.ts b/src/ui/studioAppBarModel.ts index 1f64a4a..530d865 100644 --- a/src/ui/studioAppBarModel.ts +++ b/src/ui/studioAppBarModel.ts @@ -1,5 +1,5 @@ export interface StudioAppBarContextAction { - readonly id: 'properties' | 'selectParent' | 'clearSelection'; + readonly id: 'properties' | 'bindings' | 'selectParent' | 'clearSelection'; readonly label: string; } @@ -15,7 +15,10 @@ export function resolveStudioAppBarContextActions( return []; } - const actions: StudioAppBarContextAction[] = [{ id: 'properties', label: 'Properties' }]; + const actions: StudioAppBarContextAction[] = [ + { id: 'properties', label: 'Properties' }, + { id: 'bindings', label: 'Bindings' }, + ]; if (args.parentNodeId) { actions.push({ id: 'selectParent', label: 'Select parent' }); diff --git a/src/ui/useStudioAppBarAugmentation.ts b/src/ui/useStudioAppBarAugmentation.ts index 71a6fd4..cbf8b46 100644 --- a/src/ui/useStudioAppBarAugmentation.ts +++ b/src/ui/useStudioAppBarAugmentation.ts @@ -4,6 +4,7 @@ import React, { useCallback } from 'react'; import { useStudio } from '../core/StudioContext'; import { + createStudioBindingsRoutePath, createStudioPropertiesRoutePath, isStudioAdminPath, resolveStudioLastNonAdminLocation, @@ -40,6 +41,11 @@ export function useStudioAppBarAugmentation(): StudioAppBarAugmentation { selectedNodeId: studio.selectedNodeId, }); + const openBindings = useCallback(() => { + if (!selection.selectedNodeId) return; + router.push(createStudioBindingsRoutePath(selection.selectedNodeId)); + }, [router, selection.selectedNodeId]); + const openProperties = useCallback(() => { if (!selection.selectedNodeId) return; router.push(createStudioPropertiesRoutePath(selection.selectedNodeId)); @@ -74,7 +80,9 @@ export function useStudioAppBarAugmentation(): StudioAppBarAugmentation { const handler = action.id === 'properties' ? openProperties - : action.id === 'selectParent' + : action.id === 'bindings' + ? openBindings + : action.id === 'selectParent' ? selectParent : clearSelection; return React.createElement(IconButton, { @@ -82,7 +90,9 @@ export function useStudioAppBarAugmentation(): StudioAppBarAugmentation { icon: action.id === 'properties' ? { name: 'options-outline' } - : action.id === 'selectParent' + : action.id === 'bindings' + ? { name: 'git-branch-outline' } + : action.id === 'selectParent' ? { name: 'arrow-up-outline' } : { name: 'close-outline' }, label: action.label, From c07d8d45bff0e3c9f6b2647216eaec916a9d95e1 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:29:09 +0200 Subject: [PATCH 26/58] test: cover contextual bindings app bar action --- src/ui/useStudioAppBarAugmentation.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/useStudioAppBarAugmentation.test.ts b/src/ui/useStudioAppBarAugmentation.test.ts index 4a5f782..dead129 100644 --- a/src/ui/useStudioAppBarAugmentation.test.ts +++ b/src/ui/useStudioAppBarAugmentation.test.ts @@ -14,6 +14,7 @@ test('uses the URL as the admin route source of truth', () => { expect(source).toContain('usePathname()'); expect(source).toContain('useRouter()'); expect(source).toContain("router.push('/ankh')"); + expect(source).toContain('createStudioBindingsRoutePath'); expect(source).toContain('resolveStudioLastNonAdminLocation'); expect(source).toContain('studio.setLastNonAdminLocation(appLocation)'); expect(source).toContain('Administration'); @@ -45,6 +46,7 @@ test('resolves contextual app bar actions for selected nodes', () => { expect(actions).toEqual([ { id: 'properties', label: 'Properties' }, + { id: 'bindings', label: 'Bindings' }, { id: 'selectParent', label: 'Select parent' }, { id: 'clearSelection', label: 'Clear selection' }, ]); @@ -58,6 +60,7 @@ test('omits parent selection when no parent is available', () => { expect(actions).toEqual([ { id: 'properties', label: 'Properties' }, + { id: 'bindings', label: 'Bindings' }, { id: 'clearSelection', label: 'Clear selection' }, ]); }); From cf7b47088e843eee1356878e1c0f3660a2ff038d Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:29:35 +0200 Subject: [PATCH 27/58] test: cover contextual binding routes --- src/studioAdminRouteModel.test.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/studioAdminRouteModel.test.ts b/src/studioAdminRouteModel.test.ts index aeeac87..a38d1fd 100644 --- a/src/studioAdminRouteModel.test.ts +++ b/src/studioAdminRouteModel.test.ts @@ -3,12 +3,14 @@ import { describe, expect, test } from 'bun:test'; import { createStudioAdminRoutePath, createStudioAdminRouteRenderState, + createStudioBindingsRoutePath, createStudioPropertiesRoutePath, isStudioAdminRouteActive, isStudioAdminRouteAvailable, openStudioAdminRoute, resolveStudioAdminRouteId, resolveStudioAdminRoutePath, + resolveStudioBindingsNodeId, resolveStudioLastNonAdminLocation, resolveStudioNavigableLocation, resolveStudioPropertiesNodeId, @@ -28,6 +30,7 @@ describe('studioAdminRouteModel', () => { 'auth-profile', 'secrets', 'theme', + 'bindings', 'properties', ]); }); @@ -43,12 +46,23 @@ describe('studioAdminRouteModel', () => { expect(resolveStudioAdminRouteId('/ankh/auth/profile')).toBe('auth-profile'); expect(resolveStudioAdminRouteId('/ankh/secrets')).toBe('secrets'); expect(resolveStudioAdminRouteId('/ankh/theme')).toBe('theme'); + expect(resolveStudioAdminRouteId('/ankh/bindings/node-1')).toBe('bindings'); expect(resolveStudioAdminRouteId('/ankh/properties/node-1')).toBe('properties'); + expect(resolveStudioAdminRoutePath('/ankh/bindings/node-1')).toBe('/ankh/bindings/node-1'); expect(resolveStudioAdminRoutePath('/ankh/properties/node-1')).toBe('/ankh/properties/node-1'); expect(resolveStudioAdminRouteId('/app')).toBeNull(); }); - test('resolves properties node ids and creates properties paths', () => { + test('resolves contextual node ids and creates contextual paths', () => { + expect(resolveStudioBindingsNodeId('/ankh/bindings/node-1')).toBe('node-1'); + expect(resolveStudioBindingsNodeId('/ankh/bindings/node%201')).toBe('node 1'); + expect(resolveStudioBindingsNodeId('/ankh/apis')).toBeNull(); + expect(createStudioBindingsRoutePath('node 1')).toBe('/ankh/bindings/node%201'); + expect(createStudioAdminRoutePath({ routeId: 'bindings', selectedNodeId: 'node 1' })).toBe( + '/ankh/bindings/node%201', + ); + expect(createStudioAdminRoutePath({ routeId: 'bindings', selectedNodeId: null })).toBeNull(); + expect(resolveStudioPropertiesNodeId('/ankh/properties/node-1')).toBe('node-1'); expect(resolveStudioPropertiesNodeId('/ankh/properties/node%201')).toBe('node 1'); expect(resolveStudioPropertiesNodeId('/ankh/apis')).toBeNull(); @@ -69,10 +83,17 @@ describe('studioAdminRouteModel', () => { routeAdminId: 'auth-providers', resolvedAdminRouteId: 'auth-providers', routeAdminPath: '/ankh/auth/providers', + bindingsNodeId: null, propertiesNodeId: null, shouldRenderAppContent: false, shouldRenderAdminShell: true, }); + expect( + createStudioAdminRouteRenderState({ + pathname: '/ankh/bindings/button-1', + activeAdminRouteId: 'overview', + }).bindingsNodeId, + ).toBe('button-1'); }); test('tracks hierarchy and contextual availability', () => { @@ -88,6 +109,8 @@ describe('studioAdminRouteModel', () => { candidateRouteId: 'apis', }), ).toBe(false); + expect(isStudioAdminRouteAvailable('bindings', { selectedNodeId: null })).toBe(false); + expect(isStudioAdminRouteAvailable('bindings', { selectedNodeId: 'node-1' })).toBe(true); expect(isStudioAdminRouteAvailable('properties', { selectedNodeId: null })).toBe(false); expect(isStudioAdminRouteAvailable('properties', { selectedNodeId: 'node-1' })).toBe(true); }); @@ -105,7 +128,7 @@ describe('studioAdminRouteModel', () => { expect( openStudioAdminRoute({ - next: 'properties', + next: 'bindings', selectedNodeId: null, setActivePanelId: (panelId) => panelIds.push(panelId), pushRoute: (routePath) => routes.push(routePath), From bb1e6376f98162aa9bd368146e823c2c860efdc4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:29:48 +0200 Subject: [PATCH 28/58] test: cover bindings admin page boundary --- src/ui/admin/AnkhAdminPage.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/admin/AnkhAdminPage.test.ts b/src/ui/admin/AnkhAdminPage.test.ts index 3f04558..133af53 100644 --- a/src/ui/admin/AnkhAdminPage.test.ts +++ b/src/ui/admin/AnkhAdminPage.test.ts @@ -14,8 +14,11 @@ test('uses a declarative UI page registry for every admin route id', () => { expect(source).not.toContain("if (routeId === '"); }); -test('uses canonical properties decoding in the public page boundary', () => { +test('uses canonical contextual node decoding in the public page boundary', () => { + expect(source).toContain('resolveStudioBindingsNodeId'); expect(source).toContain('resolveStudioPropertiesNodeId'); + expect(source).not.toContain("'/ankh/bindings/' +"); + expect(source).not.toContain('`/ankh/bindings/${'); expect(source).not.toContain("'/ankh/properties/' +"); expect(source).not.toContain('`/ankh/properties/${'); }); From 83522619ee685ebe58d1cc6e564f4df6822be3f3 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:30:18 +0200 Subject: [PATCH 29/58] test: cover canonical binding manifest round trip --- src/bindingManifestPersistence.test.ts | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/bindingManifestPersistence.test.ts diff --git a/src/bindingManifestPersistence.test.ts b/src/bindingManifestPersistence.test.ts new file mode 100644 index 0000000..920719d --- /dev/null +++ b/src/bindingManifestPersistence.test.ts @@ -0,0 +1,45 @@ +import type { ComponentDataBindingRegistry } from '@ankhorage/contracts'; +import { describe, expect, test } from 'bun:test'; + +import { updateStudioManifestDraftDataBindings } from './core/studioManifestDraftModel'; +import type { StudioManifest } from './index'; +import { createStudioManifestSignature } from './manifestSync'; + +function createManifest(): StudioManifest { + return { + navigator: { type: 'stack', routes: [] }, + screens: {}, + dataSources: {}, + themes: [], + activeThemeId: 'default', + settings: { localization: { defaultLocale: 'en', locales: ['en'] } }, + infra: { plugins: [] }, + } as unknown as StudioManifest; +} + +describe('binding manifest persistence', () => { + test('round-trips canonical data bindings through the manifest draft and signature', () => { + const registry: ComponentDataBindingRegistry = { + 'button-1': { + componentId: 'button-1', + componentType: 'Button', + props: { + children: { source: { kind: 'context', path: 'session.user.name' } }, + }, + events: { + press: [ + { + target: { kind: 'action', type: 'navigate' }, + input: { route: { kind: 'literal', value: '/done' } }, + }, + ], + }, + }, + }; + const next = updateStudioManifestDraftDataBindings(createManifest(), registry); + const serialized = JSON.parse(JSON.stringify(next)) as StudioManifest; + + expect(serialized.dataBindings).toEqual(registry); + expect(createStudioManifestSignature(serialized)).toBe(createStudioManifestSignature(next)); + }); +}); From 839d8f37ee334dd4c1205817a4d80234cde5742c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:30:32 +0200 Subject: [PATCH 30/58] test: cover canonical binding mutation variants --- src/bindingMutationAcceptance.test.ts | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/bindingMutationAcceptance.test.ts diff --git a/src/bindingMutationAcceptance.test.ts b/src/bindingMutationAcceptance.test.ts new file mode 100644 index 0000000..40664f3 --- /dev/null +++ b/src/bindingMutationAcceptance.test.ts @@ -0,0 +1,53 @@ +import type { UiNode } from '@ankhorage/contracts'; +import { describe, expect, test } from 'bun:test'; + +import { appendStudioEventBinding, upsertStudioPropBinding } from './bindingAuthoringModel'; + +const button: UiNode = { id: 'button-1', type: 'Button' }; + +describe('binding mutation acceptance', () => { + test('authors literal, state, context, and operation property sources', () => { + const literal = upsertStudioPropBinding({}, button, 'children', { + source: { kind: 'literal', value: 'Save' }, + }); + const state = upsertStudioPropBinding(literal, button, 'children', { + source: { kind: 'state', path: 'draft.label' }, + }); + const context = upsertStudioPropBinding(state, button, 'children', { + source: { kind: 'context', path: 'session.label' }, + }); + const operation = upsertStudioPropBinding(context, button, 'children', { + source: { + kind: 'operation', + operation: { dataSourceId: 'catalog', endpointId: 'items', operationId: 'items.read' }, + path: 'title', + }, + }); + + expect(literal[button.id]?.props?.children?.source.kind).toBe('literal'); + expect(state[button.id]?.props?.children?.source.kind).toBe('state'); + expect(context[button.id]?.props?.children?.source.kind).toBe('context'); + expect(operation[button.id]?.props?.children?.source.kind).toBe('operation'); + }); + + test('authors action and operation event targets', () => { + const withAction = appendStudioEventBinding({}, button, 'press', { + target: { kind: 'action', type: 'navigate' }, + input: { route: { kind: 'literal', value: '/done' } }, + }); + const withOperation = appendStudioEventBinding(withAction, button, 'press', { + target: { + kind: 'operation', + operation: { dataSourceId: 'generated', endpointId: 'items', operationId: 'items.create' }, + }, + input: { + name: { kind: 'source', source: { kind: 'event', path: 'value' } }, + }, + }); + + expect(withOperation[button.id]?.events?.press?.map((binding) => binding.target.kind)).toEqual([ + 'action', + 'operation', + ]); + }); +}); From 1aaca394e91d0758aee555991d14e8428a253c71 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:31:28 +0200 Subject: [PATCH 31/58] fix: stabilize and extend event input authoring --- .../pages/bindings/EventBindingComposer.tsx | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/ui/admin/pages/bindings/EventBindingComposer.tsx b/src/ui/admin/pages/bindings/EventBindingComposer.tsx index 1db1c93..0aeff21 100644 --- a/src/ui/admin/pages/bindings/EventBindingComposer.tsx +++ b/src/ui/admin/pages/bindings/EventBindingComposer.tsx @@ -52,7 +52,7 @@ export function EventBindingComposer(props: { : (selectedOperation?.inputFields ?? []), [actionType, selectedOperation?.inputFields, targetKind], ); - const eventFields = eventMeta.payload?.fields ?? []; + const eventFields = useMemo(() => eventMeta.payload?.fields ?? [], [eventMeta.payload?.fields]); const [drafts, setDrafts] = useState>>({}); useEffect(() => { @@ -148,28 +148,23 @@ function EventInputDrafts(props: { - {draft.kind === 'event' && props.eventFields.length > 0 ? ( - - props.onChange({ ...props.drafts, [field.name]: { ...draft, value } }) - } - /> - )} + + props.onChange({ ...props.drafts, [field.name]: { ...draft, value } }) + } + /> ); })} + {props.eventFields.length > 0 ? ( + + Known payload paths: {props.eventFields.join(', ')}. Nested paths may be entered manually. + + ) : null} ); } From 503868589bd952c168a23fbf7893cc0ea2c9e558 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:32:00 +0200 Subject: [PATCH 32/58] fix: diagnose nested event input paths --- src/bindingDiagnosticsModel.ts | 57 ++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/bindingDiagnosticsModel.ts b/src/bindingDiagnosticsModel.ts index d2cafaa..cbc246c 100644 --- a/src/bindingDiagnosticsModel.ts +++ b/src/bindingDiagnosticsModel.ts @@ -4,6 +4,7 @@ import type { DataSourceRegistry, EventBinding, PropBinding, + UiBindableValueMeta, UiComponentMetaRegistry, UiNode, } from '@ankhorage/contracts'; @@ -36,12 +37,10 @@ export function diagnoseStudioComponentBindings(args: { return [ ...Object.entries(binding.props ?? {}).flatMap(([name, prop]) => - diagnosePropBinding(name, prop, meta.props?.[name], args), + diagnosePropBinding(name, prop, args), ), ...Object.entries(binding.events ?? {}).flatMap(([name, events]) => - events.flatMap((event, index) => - diagnoseEventBinding(name, index, event, meta.events?.[name], args), - ), + events.flatMap((event, index) => diagnoseEventBinding(name, index, event, args)), ), ]; } @@ -49,12 +48,12 @@ export function diagnoseStudioComponentBindings(args: { function diagnosePropBinding( name: string, binding: PropBinding, - meta: UiComponentMetaRegistry[string]['bindings'] extends infer _T ? unknown : never, args: Parameters[0], ): readonly StudioBindingDiagnostic[] { const propMeta = args.componentMeta[args.node.type]?.bindings?.props?.[name]; - if (!propMeta) + if (!propMeta) { return [diagnostic('unknown-prop', `Property '${name}' is not bindable.`, `props.${name}`)]; + } if (binding.source.kind !== 'operation') return []; const runtimeDiagnostics = validateRuntimeBindingOperationRef( @@ -103,21 +102,18 @@ function diagnoseEventBinding( eventName: string, index: number, binding: EventBinding, - _meta: unknown, args: Parameters[0], ): readonly StudioBindingDiagnostic[] { const eventMeta = args.componentMeta[args.node.type]?.bindings?.events?.[eventName]; const path = `events.${eventName}.${index}`; - if (!eventMeta) + if (!eventMeta) { return [diagnostic('unknown-event', `Event '${eventName}' is not bindable.`, path)]; + } if (binding.target.kind === 'action') { - if (!args.actionTypes.includes(binding.target.type)) { - return [ - diagnostic('missing-action', `Action '${binding.target.type}' is unavailable.`, path), - ]; - } - return []; + return args.actionTypes.includes(binding.target.type) + ? [] + : [diagnostic('missing-action', `Action '${binding.target.type}' is unavailable.`, path)]; } const runtimeDiagnostics = validateRuntimeBindingOperationRef( @@ -176,35 +172,50 @@ function diagnoseInputCompatibility( path: string, ): readonly StudioBindingDiagnostic[] { if (input.kind !== 'source' || input.source.kind !== 'event') return []; - const eventField = eventFields.find((candidate) => candidate.path === input.source.path); - if (!eventField) + const eventValue = resolveEventSourceValue(input.source.path, eventFields); + if (!eventValue) { return [ diagnostic('incompatible-input', `Event path '${input.source.path}' is unavailable.`, path), ]; - const compatibility = assessStudioBindingCompatibility(field.value, { - type: toBindableType(eventField.type), - }); + } + const compatibility = assessStudioBindingCompatibility(field.value, eventValue); return compatibility === 'incompatible' ? [ diagnostic( 'incompatible-input', - `Event field '${eventField.path}' is incompatible with ${field.value.type}.`, + `Event path '${input.source.path}' is incompatible with ${field.value.type}.`, path, ), ] : []; } -function toBindableType(type: string) { +function resolveEventSourceValue( + path: string, + eventFields: readonly { readonly path: string; readonly type: string }[], +): UiBindableValueMeta | undefined { + const exact = eventFields.find((candidate) => candidate.path === path); + if (exact) return { type: toBindableType(exact.type) }; + + const parent = eventFields.find( + (candidate) => + path.startsWith(`${candidate.path}.`) && + (candidate.type === 'object' || candidate.type === 'record' || candidate.type === 'unknown'), + ); + return parent ? { type: 'unknown' } : undefined; +} + +function toBindableType(type: string): UiBindableValueMeta['type'] { if ( type === 'string' || type === 'number' || type === 'boolean' || type === 'object' || type === 'record' - ) + ) { return type; - return 'unknown' as const; + } + return 'unknown'; } function diagnostic( From 9332a4393d37867907341ab0c14df60597616db4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:32:16 +0200 Subject: [PATCH 33/58] chore: add ADM 6 changeset --- .changeset/adm-6-binding-authoring.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/adm-6-binding-authoring.md diff --git a/.changeset/adm-6-binding-authoring.md b/.changeset/adm-6-binding-authoring.md new file mode 100644 index 0000000..5a2b1f2 --- /dev/null +++ b/.changeset/adm-6-binding-authoring.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/studio': minor +--- + +Restore contextual component binding authoring with ZORA-owned bindable metadata, canonical manifest data bindings, uniform external/generated operation selection, schema-aware response paths, event payload input mapping, and binding diagnostics while keeping execution in Runtime. From 0f1b0efa0572410a4160d07028cb054b3f6bdd84 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:32:29 +0200 Subject: [PATCH 34/58] docs: document canonical binding authoring --- docs/binding-authoring.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/binding-authoring.md diff --git a/docs/binding-authoring.md b/docs/binding-authoring.md new file mode 100644 index 0000000..3ec2975 --- /dev/null +++ b/docs/binding-authoring.md @@ -0,0 +1,27 @@ +# Binding authoring + +Studio exposes one contextual binding surface at `/ankh/bindings/:nodeId`. It is opened from the selected component through the Studio AppBar and is intentionally separate from the Properties editor. + +## Ownership + +Studio owns authoring UX and manifest mutation only. Canonical binding shapes come from `@ankhorage/contracts`, bindable component props/events and event payload metadata come from ZORA, and binding execution remains in `@ankhorage/runtime`. + +Studio does not maintain a second endpoint registry, event schema registry, or binding executor. + +## Property/data bindings + +The editor only exposes properties declared in `ZORA_BINDABLE_COMPONENT_META`. Authored bindings persist in `manifest.dataBindings` and support the canonical literal, state, context, and operation-result sources. + +Operation-result bindings enumerate normalized `manifest.dataSources` uniformly, so external and generated APIs use the same authoring path. Response paths are derived from canonical operation response schemas and compared against ZORA bindable value metadata. + +## Event/action bindings + +The editor only exposes events declared by ZORA binding metadata. Events can target canonical actions or canonical data-source operations. + +Known event payload fields are surfaced from ZORA. Action payload fields come from Studio's existing action authoring metadata, while operation request inputs come from canonical operation request schemas. Inputs can use literal values or event payload paths, including nested paths beneath object/record payload fields. + +## Diagnostics + +Studio reuses Runtime operation-reference validation for missing data sources, endpoints, and operations. Studio adds authoring diagnostics for missing response paths, incompatible response values, missing required operation inputs, unknown bindable props/events, and unavailable actions. + +Diagnostics never execute operations. Runtime remains the only execution owner. From 784a50b74adc82fd93f30830f04f03b934eb3226 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:32:39 +0200 Subject: [PATCH 35/58] ci: verify ADM 6 code --- .github/workflows/verify-adm6-code.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/verify-adm6-code.yml diff --git a/.github/workflows/verify-adm6-code.yml b/.github/workflows/verify-adm6-code.yml new file mode 100644 index 0000000..b357a7b --- /dev/null +++ b/.github/workflows/verify-adm6-code.yml @@ -0,0 +1,22 @@ +name: Verify ADM 6 Code + +on: + push: + branches: + - agent/adm-6-binding-authoring + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + - run: bun install --frozen-lockfile + - run: bun run build + - run: bun run test + - run: bun run typecheck From ed04b00f60a212a7efcff6c4f9256817b89d66df Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:33:21 +0200 Subject: [PATCH 36/58] refactor: key binding operations by canonical refs --- src/ui/admin/pages/bindings/bindingEditorModel.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/admin/pages/bindings/bindingEditorModel.ts b/src/ui/admin/pages/bindings/bindingEditorModel.ts index 05aa80e..6b7eed6 100644 --- a/src/ui/admin/pages/bindings/bindingEditorModel.ts +++ b/src/ui/admin/pages/bindings/bindingEditorModel.ts @@ -1,5 +1,6 @@ import type { BindingInputMap, + BindingOperationRef, BindingValue, EventBinding, PropBinding, @@ -93,8 +94,8 @@ export function formatStudioBindingLiteral(value: BindingValue): string { return typeof value === 'string' ? value : JSON.stringify(value); } -export function createStudioOperationKey(option: StudioBindingOperationOption): string { - const { dataSourceId, endpointId, operationId } = option.operation; +export function createStudioOperationKey(operation: BindingOperationRef): string { + const { dataSourceId, endpointId, operationId } = operation; return `${dataSourceId}::${endpointId ?? ''}::${operationId}`; } @@ -102,7 +103,7 @@ export function findStudioOperationByKey( operations: readonly StudioBindingOperationOption[], key: string, ): StudioBindingOperationOption | undefined { - return operations.find((option) => createStudioOperationKey(option) === key); + return operations.find((option) => createStudioOperationKey(option.operation) === key); } function createStudioEventInputMap( From d3a4688c19750f16446b1a28b9f9bddec89bab0c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:33:52 +0200 Subject: [PATCH 37/58] refactor: use canonical operation keys in property bindings --- .../pages/bindings/PropertyBindingEditor.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx b/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx index 4954c54..0716d95 100644 --- a/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx +++ b/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx @@ -26,13 +26,11 @@ export function PropertyBindingEditor(props: { readonly onChange: (binding: PropBinding) => void; readonly onRemove: () => void; }) { - const { binding, onChange, onRemove, operationOptions, option, operations } = { - ...props, - operationOptions: props.operations.map((operation) => ({ - value: createStudioOperationKey(operation), - label: operation.label, - })), - }; + const { binding, onChange, onRemove, option, operations } = props; + const operationOptions = operations.map((operation) => ({ + value: createStudioOperationKey(operation.operation), + label: operation.label, + })); const sourceKind = binding?.source.kind ?? 'literal'; return ( @@ -66,7 +64,9 @@ export function PropertyBindingEditor(props: { ) : ( @@ -104,7 +104,10 @@ function PropertyBindingSourceFields(props: { - onChange({ ...binding, source: { kind: 'literal', value: parseStudioBindingLiteral(value, expected) } }) + onChange({ + ...binding, + source: { kind: 'literal', value: parseStudioBindingLiteral(value, expected) }, + }) } /> ); @@ -124,13 +127,7 @@ function PropertyBindingSourceFields(props: { return Event sources are not available for persistent property bindings.; } - const operationKey = createStudioOperationKey({ - operation: source.operation, - label: '', - sourceLabel: '', - inputFields: [], - responsePaths: [], - }); + const operationKey = createStudioOperationKey(source.operation); const operation = findStudioOperationByKey(operations, operationKey); const responseOptions = (operation?.responsePaths ?? []).map((response) => { const compatibility = assessStudioBindingCompatibility(expected, response.value); From 523d21e340e4117bfd1094a969787a6c33abdb2e Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:34:20 +0200 Subject: [PATCH 38/58] refactor: use canonical operation refs in event composer --- src/ui/admin/pages/bindings/EventBindingComposer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/admin/pages/bindings/EventBindingComposer.tsx b/src/ui/admin/pages/bindings/EventBindingComposer.tsx index 0aeff21..964f4b1 100644 --- a/src/ui/admin/pages/bindings/EventBindingComposer.tsx +++ b/src/ui/admin/pages/bindings/EventBindingComposer.tsx @@ -42,7 +42,7 @@ export function EventBindingComposer(props: { const [targetKind, setTargetKind] = useState<'action' | 'operation'>('action'); const [actionType, setActionType] = useState(ACTION_OPTIONS[0]?.value ?? 'navigate'); const [operationKey, setOperationKey] = useState( - operations[0] ? createStudioOperationKey(operations[0]) : '', + operations[0] ? createStudioOperationKey(operations[0].operation) : '', ); const selectedOperation = findStudioOperationByKey(operations, operationKey); const fields = useMemo( @@ -88,7 +88,7 @@ export function EventBindingComposer(props: { @@ -89,7 +93,14 @@ function EventPayloadSummary(props: { function describeEventBinding(binding: { readonly target: | { readonly kind: 'action'; readonly type: string } - | { readonly kind: 'operation'; readonly operation: { readonly dataSourceId: string; readonly endpointId?: string; readonly operationId: string } }; + | { + readonly kind: 'operation'; + readonly operation: { + readonly dataSourceId: string; + readonly endpointId?: string; + readonly operationId: string; + }; + }; readonly input?: Readonly>; }): string { if (binding.target.kind === 'action') return `Action · ${binding.target.type}`; diff --git a/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx b/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx index 0716d95..7a22653 100644 --- a/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx +++ b/src/ui/admin/pages/bindings/PropertyBindingEditor.tsx @@ -124,7 +124,9 @@ function PropertyBindingSourceFields(props: { } if (source.kind === 'event') { - return Event sources are not available for persistent property bindings.; + return ( + Event sources are not available for persistent property bindings. + ); } const operationKey = createStudioOperationKey(source.operation);