@cynodia/axiom-runtime 0.5.0-alpha.1 → 0.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,9 +9,12 @@ The domain-independent runtime: the state store, expression evaluation, the muta
9
9
  engine, constraint checking, the semantic UI renderer and routing. It takes its whole
10
10
  environment through a `HostEnvironment`, so it runs in a browser or headlessly.
11
11
 
12
- The renderer reads presentation that the compiler has already resolved and turns it into
12
+ The renderer reads presentation the compiler has already resolved and turns it into
13
13
  semantic class names, landmark and heading elements, and formatted values. It writes no
14
- styles and computes no lengths — what those classes mean is the theme's business.
14
+ styles and computes no lengths.
15
+
16
+ Main exports: `createAxiomRuntime`, `createBrowserHost`, `createMemoryHost`,
17
+ `RUNTIME_DIAGNOSTIC_CODES`, `formatValue`, `presentationClassList`.
15
18
 
16
19
  ## Installation
17
20
 
@@ -25,6 +28,13 @@ Most applications should install the facade package instead, which re-exports th
25
28
  npm install @cynodia/axiom@alpha
26
29
  ```
27
30
 
31
+
32
+ ## Documentation
33
+
34
+ The canonical operational contract lives in the `docs/` directory of the
35
+ [`@cynodia/axiom`](https://www.npmjs.com/package/@cynodia/axiom) package, and in
36
+ [the repository](https://github.com/cynodia/axiom). Start with `docs/AGENT_REFERENCE.md`.
37
+
28
38
  ## License
29
39
 
30
40
  MIT
@@ -1,4 +1,4 @@
1
- import type { ResolvedPresentation, TextRole, UxRole } from '@cynodia/axiom-core';
1
+ import type { ResolvedPresentation, UxRole } from '@cynodia/axiom-core';
2
2
  /** Every class a node's resolved presentation implies, in a stable order. */
3
3
  export declare function presentationClassList(resolved: ResolvedPresentation | undefined): string[];
4
4
  export declare function presentationClasses(resolved: ResolvedPresentation | undefined, ...extra: string[]): string;
@@ -9,10 +9,13 @@ export declare function presentationClasses(resolved: ResolvedPresentation | und
9
9
  export declare function landmarkTag(uxRole: UxRole | undefined): string | undefined;
10
10
  /**
11
11
  * Headings are real headings, so the document has an outline rather than a set of large
12
- * words. The three heading text roles are three levels: an application title, a title
13
- * within it, and a section heading under that.
12
+ * words.
13
+ *
14
+ * The element follows the resolved **`headingLevel`**, never the type scale: a value drawn
15
+ * at `display` size with `headingLevel: 'none'` is a `<span>`, which is what a monetary
16
+ * total or a dashboard statistic should be.
14
17
  */
15
- export declare function headingTag(textRole: TextRole | undefined): string | undefined;
18
+ export declare function headingTag(resolved: ResolvedPresentation | undefined): string | undefined;
16
19
  /** The ARIA role a UX role implies, where one is warranted. */
17
20
  export declare function ariaRoleFor(uxRole: UxRole | undefined): string | undefined;
18
21
  //# sourceMappingURL=presentation-classes.d.ts.map
@@ -6,7 +6,12 @@
6
6
  * the same resolved presentation can drive an entirely different renderer.
7
7
  */
8
8
  function layoutClasses(prefix, layout) {
9
- const classes = [`axiom-${prefix}layout-${layout.kind}`, `axiom-${prefix}gap-${layout.gap}`];
9
+ // A `none` token is the absence of the property, not a value to assert. Emitting it
10
+ // would override the component rules that give a control its own metrics.
11
+ const classes = [`axiom-${prefix}layout-${layout.kind}`];
12
+ if (layout.gap !== 'none') {
13
+ classes.push(`axiom-${prefix}gap-${layout.gap}`);
14
+ }
10
15
  if (!prefix) {
11
16
  classes.push(`axiom-align-${layout.align}`, `axiom-justify-${layout.justify}`);
12
17
  classes.push(layout.wrap ? 'axiom-wrap' : 'axiom-nowrap');
@@ -32,7 +37,14 @@ function sizingClasses(prefix, sizing) {
32
37
  return classes;
33
38
  }
34
39
  function paddingClasses(prefix, padding) {
35
- return [`axiom-${prefix}pad-x-${padding.horizontal}`, `axiom-${prefix}pad-y-${padding.vertical}`];
40
+ const classes = [];
41
+ if (padding.horizontal !== 'none') {
42
+ classes.push(`axiom-${prefix}pad-x-${padding.horizontal}`);
43
+ }
44
+ if (padding.vertical !== 'none') {
45
+ classes.push(`axiom-${prefix}pad-y-${padding.vertical}`);
46
+ }
47
+ return classes;
36
48
  }
37
49
  function responsiveClasses(device, override) {
38
50
  const prefix = `${device}-`;
@@ -115,20 +127,15 @@ export function landmarkTag(uxRole) {
115
127
  }
116
128
  /**
117
129
  * Headings are real headings, so the document has an outline rather than a set of large
118
- * words. The three heading text roles are three levels: an application title, a title
119
- * within it, and a section heading under that.
130
+ * words.
131
+ *
132
+ * The element follows the resolved **`headingLevel`**, never the type scale: a value drawn
133
+ * at `display` size with `headingLevel: 'none'` is a `<span>`, which is what a monetary
134
+ * total or a dashboard statistic should be.
120
135
  */
121
- export function headingTag(textRole) {
122
- switch (textRole) {
123
- case 'display':
124
- return 'h1';
125
- case 'title':
126
- return 'h2';
127
- case 'heading':
128
- return 'h3';
129
- default:
130
- return undefined;
131
- }
136
+ export function headingTag(resolved) {
137
+ const level = resolved?.headingLevel;
138
+ return typeof level === 'number' ? `h${level}` : undefined;
132
139
  }
133
140
  /** The ARIA role a UX role implies, where one is warranted. */
134
141
  export function ariaRoleFor(uxRole) {
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ApplicationIR, CompiledRoute, FieldId, Location, NodeId } from '@cynodia/axiom-core';
1
+ import type { ApplicationIR, CompiledRoute, Expression, FieldId, Location, NodeId } from '@cynodia/axiom-core';
2
2
  import type { DomElement, HostEnvironment } from './dom.js';
3
3
  import type { MutationLogEntry } from './mutation/mutation-engine.js';
4
4
  /**
@@ -29,8 +29,16 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
29
29
  readonly UNSUPPORTED_UI_NODE: "UNSUPPORTED_UI_NODE";
30
30
  readonly INPUT_REJECTED: "INPUT_REJECTED";
31
31
  readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
32
+ /** A local write was attempted against state whose authority is the server. */
33
+ readonly SERVER_STATE_WRITE: "SERVER_STATE_WRITE";
34
+ /** An action belongs to the authority, but no gateway to it was configured. */
35
+ readonly REMOTE_ACTION_UNAVAILABLE: "REMOTE_ACTION_UNAVAILABLE";
32
36
  };
33
37
  export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
38
+ /**
39
+ * A structured runtime failure. Match on `code` and read `details`; the `message` is for
40
+ * people and is not a stable contract.
41
+ */
34
42
  export interface RuntimeDiagnostic {
35
43
  code: RuntimeDiagnosticCode;
36
44
  message: string;
@@ -48,6 +56,48 @@ export interface RuntimeDiagnostic {
48
56
  export interface ActionResult {
49
57
  ok: boolean;
50
58
  diagnostics: RuntimeDiagnostic[];
59
+ /**
60
+ * Set when the invocation was dispatched to the authority. `ok` is not yet meaningful:
61
+ * the outcome arrives later, and reaches the interface through the action's recorded
62
+ * outcome and any `diagnostic` node presenting it.
63
+ */
64
+ pending?: true;
65
+ }
66
+ /**
67
+ * How a client reaches the authority.
68
+ *
69
+ * The client requests **semantic actions**; it never sends operations. A remote invocation
70
+ * is dispatched and answered later, so `invokeAction` returns `pending` and the outcome
71
+ * arrives through the same diagnostic lifecycle a local refusal uses.
72
+ */
73
+ export interface RemoteGateway {
74
+ invoke(request: {
75
+ actionId: NodeId;
76
+ arguments: Record<string, unknown>;
77
+ requestId: string;
78
+ }): Promise<{
79
+ ok: boolean;
80
+ diagnostics: RuntimeDiagnostic[];
81
+ changes: Record<NodeId, unknown>;
82
+ }>;
83
+ /** The authoritative values of every observable state. */
84
+ snapshot?(): Promise<{
85
+ states: Record<NodeId, unknown>;
86
+ }>;
87
+ }
88
+ /**
89
+ * The outcome of an action's most recent invocation.
90
+ *
91
+ * `ok` and `cancelled` both carry no diagnostics, so a `diagnostic` UI node presenting
92
+ * this action shows nothing after either. They are distinguished here because the
93
+ * difference matters to an agent: `cancelled` means a person declined the confirmation,
94
+ * not that the action was refused.
95
+ */
96
+ export interface ActionOutcome {
97
+ actionId: NodeId;
98
+ /** `pending` means the request is with the authority and the outcome is not yet known. */
99
+ outcome: 'ok' | 'failed' | 'cancelled' | 'pending';
100
+ diagnostics: RuntimeDiagnostic[];
51
101
  }
52
102
  export interface RouteMatch {
53
103
  route: CompiledRoute;
@@ -55,13 +105,22 @@ export interface RouteMatch {
55
105
  parameters: Record<string, string>;
56
106
  }
57
107
  export type NativeImplementation = (inputs: Record<string, unknown>) => unknown;
58
- /** When constraints are evaluated after an input writes to its location. */
108
+ /**
109
+ * When entity constraints are evaluated after an input writes to its location.
110
+ * `'deferred'` turns the per-keystroke check off entirely, leaving validity to the next
111
+ * action. Transition constraints are unaffected by this setting and always apply.
112
+ */
59
113
  export type InputValidationMode = 'immediate' | 'deferred';
60
114
  export interface AxiomRuntimeOptions {
61
115
  ir: ApplicationIR;
62
116
  rootElement: DomElement;
63
117
  host: HostEnvironment;
64
118
  nativeOperations?: Record<string, NativeImplementation>;
119
+ /**
120
+ * How to reach the authority. Required if the application has server-authoritative
121
+ * state; without it a remote invocation reports `REMOTE_ACTION_UNAVAILABLE`.
122
+ */
123
+ remote?: RemoteGateway;
65
124
  inputValidation?: InputValidationMode;
66
125
  /** Records previous and next values in the mutation log. */
67
126
  recordMutationValues?: boolean;
@@ -69,6 +128,7 @@ export interface AxiomRuntimeOptions {
69
128
  export interface AxiomRuntime {
70
129
  start(): void;
71
130
  render(): void;
131
+ /** A deep clone of the value. Derived state is recomputed. */
72
132
  getState(id: NodeId): unknown;
73
133
  /**
74
134
  * Replaces a state value outright, for hosts, tests and seeding.
@@ -78,15 +138,50 @@ export interface AxiomRuntime {
78
138
  * belongs in actions and input bindings, which are governed.
79
139
  */
80
140
  hydrateState(id: NodeId, value: unknown): void;
141
+ /**
142
+ * Runs an action as a transaction and returns the diagnostics **of that invocation**,
143
+ * so nothing has to diff global history. Either every mutation commits or every one is
144
+ * rolled back.
145
+ */
81
146
  invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
82
147
  navigate(path: string): void;
83
148
  currentRoute(): RouteMatch | null;
84
149
  /** Every diagnostic reported so far. Per-invocation results carry their own. */
85
150
  diagnostics(): RuntimeDiagnostic[];
151
+ /** Clears the running log **and** every recorded action outcome. */
86
152
  clearDiagnostics(): void;
153
+ /**
154
+ * The outcome of this action's most recent invocation, or `undefined` if it has not been
155
+ * invoked since the last clear or route change. This is what a `diagnostic` UI node
156
+ * presents.
157
+ */
158
+ getActionOutcome(id: NodeId): ActionOutcome | undefined;
87
159
  /** Every mutation this runtime has applied, in order, with its semantic location. */
88
160
  getMutationLog(): MutationLogEntry[];
89
161
  registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
162
+ /**
163
+ * Invokes an action and waits for its outcome. For a remote action this awaits the
164
+ * authority's answer; for a local one it is `invokeAction` in promise form.
165
+ */
166
+ invokeActionAsync(id: NodeId, args?: Record<string, unknown>): Promise<ActionResult>;
167
+ /**
168
+ * Loads the authoritative snapshot and applies it. Called by `start()` when a gateway
169
+ * provides one.
170
+ */
171
+ syncAuthoritativeState(): Promise<void>;
172
+ /**
173
+ * Evaluates an expression in the root scope, reporting rather than throwing. It is a
174
+ * pure read: an expression cannot change state.
175
+ *
176
+ * An authority uses it to evaluate an authorization rule before opening a transaction.
177
+ */
178
+ evaluate(expression: Expression): {
179
+ ok: true;
180
+ value: unknown;
181
+ } | {
182
+ ok: false;
183
+ diagnostic: RuntimeDiagnostic;
184
+ };
90
185
  }
91
186
  export declare function createAxiomRuntime(options: AxiomRuntimeOptions): AxiomRuntime;
92
187
  /** Builds a host bound to the browser globals. Used by generated pages. */
package/dist/runtime.js CHANGED
@@ -33,6 +33,10 @@ export const RUNTIME_DIAGNOSTIC_CODES = {
33
33
  UNSUPPORTED_UI_NODE: 'UNSUPPORTED_UI_NODE',
34
34
  INPUT_REJECTED: 'INPUT_REJECTED',
35
35
  PERSISTED_STATE_UNREADABLE: 'PERSISTED_STATE_UNREADABLE',
36
+ /** A local write was attempted against state whose authority is the server. */
37
+ SERVER_STATE_WRITE: 'SERVER_STATE_WRITE',
38
+ /** An action belongs to the authority, but no gateway to it was configured. */
39
+ REMOTE_ACTION_UNAVAILABLE: 'REMOTE_ACTION_UNAVAILABLE',
36
40
  };
37
41
  const MISSING = Symbol('missing');
38
42
  function unwrapType(type) {
@@ -68,7 +72,7 @@ function defaultForType(type) {
68
72
  */
69
73
  function requireCollection(value, operator) {
70
74
  if (!Array.isArray(value)) {
71
- throw new ExpressionEvaluationError(`${operator} expects a collection but received ${describeValue(value)}`, { operator, received: value });
75
+ throw new ExpressionEvaluationError(`${operator} expects a collection but received ${describeValue(value)}`, { collectionOperator: operator, received: value });
72
76
  }
73
77
  return value;
74
78
  }
@@ -91,21 +95,93 @@ export function createAxiomRuntime(options) {
91
95
  const derivedCache = new Map();
92
96
  const natives = new Map(Object.entries(options.nativeOperations ?? {}));
93
97
  const diagnostics = [];
98
+ /** Rendered controls, keyed by render instance — not by node id. */
94
99
  const inputElements = new Map();
95
- let focusedNodeId = null;
100
+ let focusedInstance = null;
96
101
  let focusedCaret = null;
97
102
  let started = false;
98
103
  let transactionCounter = 0;
99
104
  const mutationLog = [];
100
105
  const inputValidation = options.inputValidation ?? 'immediate';
106
+ const remote = options.remote;
107
+ const remoteActionIds = new Set(ir.remoteActionIds ?? []);
108
+ /**
109
+ * Set only while an authoritative answer is being applied. The authority owns the value;
110
+ * every other path is refused, which is what makes the boundary structural rather than a
111
+ * convention about where inputs are bound.
112
+ */
113
+ let applyingAuthoritative = false;
114
+ let remoteRequests = 0;
101
115
  const theme = ir.theme;
102
116
  const locale = theme?.locale ?? 'en-US';
103
- /** Messages for inputs whose last write was refused, so a control can say so. */
117
+ /**
118
+ * Messages for inputs whose last write was refused, so a control can say so. Keyed by
119
+ * render instance: refusing a write in one row must not mark another row invalid.
120
+ */
104
121
  const inputErrors = new Map();
122
+ /**
123
+ * The most recent outcome of each action, which is what a `diagnostic` node presents.
124
+ * Ephemeral runtime state: it is replaced by the next invocation of the same action, and
125
+ * cleared by `clearDiagnostics()` and by navigating to another route.
126
+ */
127
+ const actionOutcomes = new Map();
128
+ /**
129
+ * Buttons that are their form's declared submit control, and the action each submits.
130
+ * Such a button carries native submit behaviour instead of its own click handler, so a
131
+ * click runs the action exactly once.
132
+ */
133
+ const submitControls = new Map();
134
+ for (const node of Object.values(ir.uiNodes)) {
135
+ if (node.kind !== 'form' || !node.submitButtonId) {
136
+ continue;
137
+ }
138
+ const button = ir.uiNodes[node.submitButtonId];
139
+ if (button?.kind === 'button') {
140
+ submitControls.set(node.submitButtonId, {
141
+ formId: node.id,
142
+ actionId: node.submitActionId ?? button.actionId,
143
+ });
144
+ }
145
+ }
146
+ /** Diagnostic regions, by the action they report. Indexed once. */
147
+ const diagnosticRegions = new Map();
148
+ for (const node of Object.values(ir.uiNodes)) {
149
+ if (node.kind === 'diagnostic') {
150
+ diagnosticRegions.set(node.actionId, [...(diagnosticRegions.get(node.actionId) ?? []), node.id]);
151
+ }
152
+ }
153
+ function recordOutcome(actionId, outcome, diagnostics) {
154
+ actionOutcomes.set(actionId, {
155
+ actionId: actionId,
156
+ outcome,
157
+ // Only what this invocation reported, and only what a region could present.
158
+ diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic })),
159
+ });
160
+ }
105
161
  /** Presentation, already resolved by the compiler. The renderer decides nothing. */
106
162
  function presentationOf(id) {
107
163
  return ir.presentation?.[id];
108
164
  }
165
+ /**
166
+ * Where a rendered element sits: the repeat instances enclosing it, outermost first.
167
+ *
168
+ * A UI node inside a `repeat` is rendered once per member, so `NodeId` alone cannot
169
+ * identify a rendered element. The graph still holds one semantic node; this is the
170
+ * runtime presentation identity that goes with it, and the two are deliberately
171
+ * distinct — `data-node` carries the first, `data-instance` the second.
172
+ */
173
+ const ROOT_INSTANCE = [];
174
+ /** Everything a renderer-generated id, relationship or lookup is keyed by. */
175
+ function instanceKey(nodeId, path) {
176
+ return path.length === 0 ? nodeId : `${nodeId}--${path.join('--')}`;
177
+ }
178
+ /** A DOM-safe fragment of an item's identity. */
179
+ function identityFragment(value, index) {
180
+ const text = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
181
+ const safe = text.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
182
+ // An absent or unusable identity falls back to a deterministic iteration index.
183
+ return safe === '' ? `i${index}` : safe;
184
+ }
109
185
  const statesById = new Map(ir.states.map((state) => [state.id, state]));
110
186
  const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
111
187
  const parameterTypes = new Map();
@@ -209,6 +285,18 @@ export function createAxiomRuntime(options) {
209
285
  }
210
286
  /** The only place the store is written. Values are frozen on the way in. */
211
287
  function writeState(stateId, value) {
288
+ if (!applyingAuthoritative && ir.authority?.[stateId] === 'server') {
289
+ // Whatever the path — an action, an input, an administrative hydrate — a client does
290
+ // not commit state the authority owns.
291
+ report({
292
+ code: RUNTIME_DIAGNOSTIC_CODES.SERVER_STATE_WRITE,
293
+ message: `${stateId} is server-authoritative and cannot be written by this client`,
294
+ severity: 'error',
295
+ nodeId: stateId,
296
+ stateId: stateId,
297
+ });
298
+ return;
299
+ }
212
300
  if (!statesById.has(stateId)) {
213
301
  report({
214
302
  code: RUNTIME_DIAGNOSTIC_CODES.UNKNOWN_STATE,
@@ -274,11 +362,25 @@ export function createAxiomRuntime(options) {
274
362
  }
275
363
  }
276
364
  }
365
+ /**
366
+ * Classifies an evaluation failure. The code says what kind of failure it was, so an
367
+ * agent can distinguish an identifier that did not resolve from a value of the wrong
368
+ * shape without reading the message.
369
+ */
370
+ function evaluationFailureCode(details) {
371
+ if (details.targetId !== undefined) {
372
+ return RUNTIME_DIAGNOSTIC_CODES.UNRESOLVED_REFERENCE;
373
+ }
374
+ if (details.kind !== undefined || details.operator !== undefined || details.function !== undefined) {
375
+ return RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION;
376
+ }
377
+ return RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED;
378
+ }
277
379
  /** Turns an evaluation failure into a diagnostic instead of letting it escape. */
278
380
  function evaluationFailure(error, context = {}) {
279
381
  if (error instanceof ExpressionEvaluationError) {
280
382
  return {
281
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
383
+ code: evaluationFailureCode(error.details),
282
384
  message: error.message,
283
385
  severity: 'error',
284
386
  ...context,
@@ -303,7 +405,9 @@ export function createAxiomRuntime(options) {
303
405
  }
304
406
  catch (error) {
305
407
  const failure = {
306
- code: error instanceof LocationResolutionError ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED : 'MUTATION_FAILED',
408
+ code: error instanceof LocationResolutionError
409
+ ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED
410
+ : RUNTIME_DIAGNOSTIC_CODES.MUTATION_FAILED,
307
411
  message: error instanceof Error ? error.message : String(error),
308
412
  severity: 'error',
309
413
  ...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
@@ -422,19 +526,26 @@ export function createAxiomRuntime(options) {
422
526
  }
423
527
  const left = evaluate(leftExpression, scope);
424
528
  const right = evaluate(rightExpression, scope);
529
+ /**
530
+ * A number that is not finite has no place in an ordering. Every ordered comparison
531
+ * against one is false, so a guard fails closed rather than passing on a value that
532
+ * could not be computed — or one a hostile caller supplied.
533
+ */
534
+ const unordered = (typeof left === 'number' && !Number.isFinite(left)) ||
535
+ (typeof right === 'number' && !Number.isFinite(right));
425
536
  switch (operator) {
426
537
  case 'eq':
427
538
  return valuesEqual(left, right);
428
539
  case 'neq':
429
540
  return !valuesEqual(left, right);
430
541
  case 'gt':
431
- return compareValues(left, right) > 0;
542
+ return !unordered && compareValues(left, right) > 0;
432
543
  case 'gte':
433
- return compareValues(left, right) >= 0;
544
+ return !unordered && compareValues(left, right) >= 0;
434
545
  case 'lt':
435
- return compareValues(left, right) < 0;
546
+ return !unordered && compareValues(left, right) < 0;
436
547
  case 'lte':
437
- return compareValues(left, right) <= 0;
548
+ return !unordered && compareValues(left, right) <= 0;
438
549
  case 'add':
439
550
  return Number(left ?? 0) + Number(right ?? 0);
440
551
  case 'subtract':
@@ -851,9 +962,109 @@ export function createAxiomRuntime(options) {
851
962
  });
852
963
  }
853
964
  }
965
+ /**
966
+ * Applies an authoritative answer. The only path permitted to write server-owned state —
967
+ * and it still goes through `writeState`, so the store keeps exactly one writer.
968
+ */
969
+ function applyAuthoritative(changes) {
970
+ applyingAuthoritative = true;
971
+ try {
972
+ for (const [stateId, value] of Object.entries(changes)) {
973
+ if (statesById.has(stateId)) {
974
+ writeState(stateId, cloneValue(value));
975
+ }
976
+ }
977
+ }
978
+ finally {
979
+ applyingAuthoritative = false;
980
+ }
981
+ }
982
+ /**
983
+ * Dispatches a semantic action to the authority.
984
+ *
985
+ * The client sends an action id and typed arguments — never operations. The answer is
986
+ * applied when it arrives, and recorded through the same outcome lifecycle a local
987
+ * refusal uses, so a `diagnostic` node presents a server refusal exactly as it presents
988
+ * a local one.
989
+ */
990
+ function runRemoteAction(action, args) {
991
+ if (!remote) {
992
+ const failure = {
993
+ code: RUNTIME_DIAGNOSTIC_CODES.REMOTE_ACTION_UNAVAILABLE,
994
+ message: `${action.name ?? action.id} executes on the authority, but no gateway to it is configured`,
995
+ severity: 'error',
996
+ nodeId: action.id,
997
+ actionId: action.id,
998
+ };
999
+ report(failure);
1000
+ recordOutcome(action.id, 'failed', [failure]);
1001
+ renderApplication();
1002
+ return { ok: false, diagnostics: [failure] };
1003
+ }
1004
+ if (action.requiresConfirmation && !askForConfirmation(action)) {
1005
+ // Confirmation is interaction, and it happens here. The authority never treats it as
1006
+ // an authorization mechanism.
1007
+ recordOutcome(action.id, 'cancelled', []);
1008
+ renderApplication();
1009
+ return { ok: false, diagnostics: [] };
1010
+ }
1011
+ remoteRequests += 1;
1012
+ // A stable key, so a retry after a lost answer cannot execute the action twice.
1013
+ const requestId = `${ir.id}:${action.id}:${remoteRequests}:${host.uuid()}`;
1014
+ recordOutcome(action.id, 'pending', []);
1015
+ renderApplication();
1016
+ const settle = remote
1017
+ .invoke({ actionId: action.id, arguments: args, requestId })
1018
+ .then((answer) => {
1019
+ applyAuthoritative(answer.changes ?? {});
1020
+ answer.diagnostics.forEach(report);
1021
+ recordOutcome(action.id, answer.ok ? 'ok' : 'failed', answer.ok ? [] : answer.diagnostics);
1022
+ renderApplication();
1023
+ return { ok: answer.ok, diagnostics: answer.diagnostics };
1024
+ })
1025
+ .catch((error) => {
1026
+ // A transport failure becomes a structured diagnostic, not an escaping exception.
1027
+ const failure = {
1028
+ code: RUNTIME_DIAGNOSTIC_CODES.REMOTE_ACTION_UNAVAILABLE,
1029
+ message: error instanceof Error ? error.message : String(error),
1030
+ severity: 'error',
1031
+ nodeId: action.id,
1032
+ actionId: action.id,
1033
+ };
1034
+ report(failure);
1035
+ recordOutcome(action.id, 'failed', [failure]);
1036
+ renderApplication();
1037
+ return { ok: false, diagnostics: [failure] };
1038
+ });
1039
+ pending.set(action.id, settle);
1040
+ return { ok: false, pending: true, diagnostics: [] };
1041
+ }
1042
+ /** In-flight remote invocations, so `invokeActionAsync` can await one. */
1043
+ const pending = new Map();
854
1044
  function runAction(actionId, args = {}) {
855
- return collecting((collected) => runActionCollecting(actionId, args, collected));
1045
+ const remoteAction = remoteActionIds.has(actionId) ? ir.actions[actionId] : undefined;
1046
+ if (remoteAction) {
1047
+ return runRemoteAction(remoteAction, args);
1048
+ }
1049
+ return collecting((collected) => {
1050
+ const started = collected.length;
1051
+ const result = runActionCollecting(actionId, args, collected);
1052
+ if (ir.actions[actionId]) {
1053
+ // The record is this invocation's own diagnostics, so a later invocation of another
1054
+ // action can never appear to belong to this one.
1055
+ recordOutcome(actionId, result.ok ? 'ok' : cancelled ? 'cancelled' : 'failed', result.ok ? [] : collected.slice(started));
1056
+ }
1057
+ cancelled = false;
1058
+ // Any invocation can change what a diagnostic region presents, including one refused
1059
+ // before a transaction was ever opened. Render once, at the outermost invocation.
1060
+ if (transactions.currentId() === undefined) {
1061
+ renderApplication();
1062
+ }
1063
+ return result;
1064
+ });
856
1065
  }
1066
+ /** Set when the most recent invocation stopped because a confirmation was declined. */
1067
+ let cancelled = false;
857
1068
  function runActionCollecting(actionId, args, collected) {
858
1069
  const action = ir.actions[actionId];
859
1070
  if (!action) {
@@ -908,6 +1119,8 @@ export function createAxiomRuntime(options) {
908
1119
  }
909
1120
  if (action.requiresConfirmation) {
910
1121
  if (!askForConfirmation(action)) {
1122
+ // Declining a confirmation is not a refusal: it reports nothing.
1123
+ cancelled = true;
911
1124
  return { ok: false, diagnostics: [...collected] };
912
1125
  }
913
1126
  }
@@ -952,11 +1165,9 @@ export function createAxiomRuntime(options) {
952
1165
  report({ ...violation, actionId: action.id, transactionId: transaction.id });
953
1166
  }
954
1167
  }
955
- renderApplication();
956
1168
  return { ok: false, diagnostics: [...collected] };
957
1169
  }
958
1170
  settle(transaction, 'committed');
959
- renderApplication();
960
1171
  return { ok: true, diagnostics: [...collected] };
961
1172
  }
962
1173
  // ------------------------------------------------------------------ routing
@@ -1004,7 +1215,13 @@ export function createAxiomRuntime(options) {
1004
1215
  syncRoute();
1005
1216
  }
1006
1217
  function syncRoute() {
1218
+ const previous = activeRoute?.route.id;
1007
1219
  activeRoute = matchRoute(host.getPath());
1220
+ if (previous !== undefined && previous !== activeRoute?.route.id) {
1221
+ // Diagnostics are about the screen that produced them.
1222
+ actionOutcomes.clear();
1223
+ inputErrors.clear();
1224
+ }
1008
1225
  derivedCache.clear();
1009
1226
  renderApplication();
1010
1227
  }
@@ -1060,9 +1277,36 @@ export function createAxiomRuntime(options) {
1060
1277
  };
1061
1278
  return host.confirmRequest(request);
1062
1279
  }
1063
- function renderChildren(ids, scope, parent) {
1280
+ /**
1281
+ * The diagnostics a region presents: those of its action's most recent invocation, at or
1282
+ * above the region's own severity.
1283
+ */
1284
+ function presentedDiagnostics(node) {
1285
+ const record = actionOutcomes.get(node.actionId);
1286
+ if (!record) {
1287
+ return [];
1288
+ }
1289
+ const minimum = node.severity ?? 'error';
1290
+ return record.diagnostics.filter((diagnostic) => minimum === 'warning' || diagnostic.severity === 'error');
1291
+ }
1292
+ /** Whether any region currently reports something about this action. */
1293
+ function reportingRegionFor(actionId, path) {
1294
+ if (path.length > 0) {
1295
+ // A region reports one action, not one row; relating a repeated control to it would
1296
+ // be guesswork. Rows report through their own input diagnostics instead.
1297
+ return null;
1298
+ }
1299
+ for (const regionId of diagnosticRegions.get(actionId) ?? []) {
1300
+ const region = ir.uiNodes[regionId];
1301
+ if (region?.kind === 'diagnostic' && presentedDiagnostics(region).length > 0) {
1302
+ return `axiom-diagnostic-${regionId}`;
1303
+ }
1304
+ }
1305
+ return null;
1306
+ }
1307
+ function renderChildren(ids, scope, parent, path) {
1064
1308
  for (const id of ids) {
1065
- const child = renderNode(id, scope);
1309
+ const child = renderNode(id, scope, path);
1066
1310
  if (child) {
1067
1311
  parent.appendChild(child);
1068
1312
  }
@@ -1169,16 +1413,16 @@ export function createAxiomRuntime(options) {
1169
1413
  }
1170
1414
  return raw;
1171
1415
  }
1172
- function renderNode(id, scope) {
1416
+ function renderNode(id, scope, path) {
1173
1417
  try {
1174
- return renderNodeUnguarded(id, scope);
1418
+ return renderNodeUnguarded(id, scope, path);
1175
1419
  }
1176
1420
  catch (error) {
1177
1421
  report(evaluationFailure(error, { nodeId: id }));
1178
1422
  return null;
1179
1423
  }
1180
1424
  }
1181
- function renderNodeUnguarded(id, scope) {
1425
+ function renderNodeUnguarded(id, scope, path) {
1182
1426
  const node = ir.uiNodes[id];
1183
1427
  if (!node) {
1184
1428
  report({
@@ -1193,18 +1437,25 @@ export function createAxiomRuntime(options) {
1193
1437
  return null;
1194
1438
  }
1195
1439
  const presentation = presentationOf(node.id);
1440
+ const instance = instanceKey(node.id, path);
1441
+ /** `data-node` is the semantic node; `data-instance` is this rendering of it. */
1442
+ const identify = (target) => {
1443
+ target.setAttribute('data-node', node.id);
1444
+ if (path.length > 0) {
1445
+ target.setAttribute('data-instance', instance);
1446
+ }
1447
+ return target;
1448
+ };
1196
1449
  switch (node.kind) {
1197
1450
  case 'view': {
1198
- const container = element('div', nodeClasses(node, 'axiom-view'));
1199
- container.setAttribute('data-node', node.id);
1200
- renderChildren(node.children, scope, container);
1451
+ const container = identify(element('div', nodeClasses(node, 'axiom-view')));
1452
+ renderChildren(node.children, scope, container, path);
1201
1453
  return container;
1202
1454
  }
1203
1455
  case 'container': {
1204
1456
  // A UX role that names a region of the page becomes the element that means it, so
1205
1457
  // the landmark structure an author declared is the one assistive technology sees.
1206
- const container = element(landmarkTag(presentation?.uxRole) ?? 'div', nodeClasses(node, 'axiom-container'));
1207
- container.setAttribute('data-node', node.id);
1458
+ const container = identify(element(landmarkTag(presentation?.uxRole) ?? 'div', nodeClasses(node, 'axiom-container')));
1208
1459
  const ariaRole = ariaRoleFor(presentation?.uxRole);
1209
1460
  if (ariaRole) {
1210
1461
  container.setAttribute('role', ariaRole);
@@ -1213,12 +1464,11 @@ export function createAxiomRuntime(options) {
1213
1464
  container.setAttribute('aria-label', presentation.accessibleLabel);
1214
1465
  }
1215
1466
  appendIcon(container, presentation);
1216
- renderChildren(node.children, scope, container);
1467
+ renderChildren(node.children, scope, container, path);
1217
1468
  return container;
1218
1469
  }
1219
1470
  case 'text': {
1220
- const text = element(headingTag(presentation?.textRole) ?? 'span', nodeClasses(node, 'axiom-text'));
1221
- text.setAttribute('data-node', node.id);
1471
+ const text = identify(element(headingTag(presentation) ?? 'span', nodeClasses(node, 'axiom-text')));
1222
1472
  // A status role announces itself whatever kind of node carries it.
1223
1473
  const textRole = ariaRoleFor(presentation?.uxRole);
1224
1474
  if (textRole) {
@@ -1240,25 +1490,27 @@ export function createAxiomRuntime(options) {
1240
1490
  return text;
1241
1491
  }
1242
1492
  case 'repeat': {
1243
- const container = element('div', nodeClasses(node, 'axiom-repeat'));
1244
- container.setAttribute('data-node', node.id);
1493
+ const container = identify(element('div', nodeClasses(node, 'axiom-repeat')));
1245
1494
  const source = evaluate(node.source, scope);
1246
1495
  const items = Array.isArray(source) ? source : [];
1247
1496
  if (items.length === 0 && node.emptyTemplateId) {
1248
- renderChildren([node.emptyTemplateId], scope, container);
1497
+ renderChildren([node.emptyTemplateId], scope, container, path);
1249
1498
  return container;
1250
1499
  }
1251
- for (const item of items) {
1252
- const child = renderNode(node.templateId, childScope(scope, node.id, item));
1500
+ // Each member gets its own render instance, preferring its semantic identity so
1501
+ // the identity survives reordering. Nested repeats compose rather than collide.
1502
+ const identityFieldId = ir.repeatIdentityFields?.[node.id];
1503
+ items.forEach((item, index) => {
1504
+ const identity = identityFieldId && isRecord(item) ? item[identityFieldId] : undefined;
1505
+ const child = renderNode(node.templateId, childScope(scope, node.id, item), [...path, identityFragment(identity, index)]);
1253
1506
  if (child) {
1254
1507
  container.appendChild(child);
1255
1508
  }
1256
- }
1509
+ });
1257
1510
  return container;
1258
1511
  }
1259
1512
  case 'field-display': {
1260
- const container = element('div', nodeClasses(node, 'axiom-field'));
1261
- container.setAttribute('data-node', node.id);
1513
+ const container = identify(element('div', nodeClasses(node, 'axiom-field')));
1262
1514
  const field = fieldOf(node.fieldId);
1263
1515
  if (node.label ?? field?.name) {
1264
1516
  const label = element('span', 'axiom-field-label');
@@ -1276,20 +1528,25 @@ export function createAxiomRuntime(options) {
1276
1528
  return container;
1277
1529
  }
1278
1530
  case 'form': {
1279
- const form = element('form', nodeClasses(node, 'axiom-form'));
1280
- form.setAttribute('data-node', node.id);
1281
- renderChildren(node.children, scope, form);
1282
- if (node.submitActionId) {
1283
- const actions = element('div', 'axiom-container axiom-ux-action-group axiom-layout-horizontal axiom-gap-small axiom-align-center axiom-justify-end axiom-wrap axiom-width-fill');
1284
- const submit = element('button', 'axiom-submit axiom-button axiom-role-primary axiom-emphasis-strong axiom-ux-primary-action');
1285
- submit.setAttribute('type', 'submit');
1286
- submit.textContent = node.submitLabel ?? 'Submit';
1287
- actions.appendChild(submit);
1288
- form.appendChild(actions);
1289
- const actionId = node.submitActionId;
1531
+ const form = identify(element('form', nodeClasses(node, 'axiom-form')));
1532
+ renderChildren(node.children, scope, form, path);
1533
+ const declaredControl = node.submitButtonId
1534
+ ? submitControls.get(node.submitButtonId)
1535
+ : undefined;
1536
+ const submitActionId = declaredControl?.actionId ?? node.submitActionId;
1537
+ if (submitActionId) {
1538
+ if (!declaredControl) {
1539
+ // The simple form: the renderer supplies the button.
1540
+ const actions = element('div', 'axiom-container axiom-ux-action-group axiom-layout-horizontal axiom-gap-small axiom-align-center axiom-justify-end axiom-wrap axiom-width-fill');
1541
+ const submit = element('button', 'axiom-submit axiom-button axiom-role-primary axiom-emphasis-strong axiom-ux-primary-action');
1542
+ submit.setAttribute('type', 'submit');
1543
+ submit.textContent = node.submitLabel ?? 'Submit';
1544
+ actions.appendChild(submit);
1545
+ form.appendChild(actions);
1546
+ }
1290
1547
  form.addEventListener('submit', (event) => {
1291
1548
  event.preventDefault?.();
1292
- runAction(actionId);
1549
+ runAction(submitActionId);
1293
1550
  });
1294
1551
  }
1295
1552
  return form;
@@ -1297,10 +1554,9 @@ export function createAxiomRuntime(options) {
1297
1554
  case 'input': {
1298
1555
  const descriptor = resolveInputTag(node);
1299
1556
  const grouped = descriptor.tag === 'radio-group';
1300
- const controlId = `axiom-control-${node.id}`;
1557
+ const controlId = `axiom-control-${instance}`;
1301
1558
  const required = ir.locationRequired?.[node.id] === true;
1302
- const wrapper = element(grouped ? 'div' : 'label', nodeClasses(node, 'axiom-input'));
1303
- wrapper.setAttribute('data-node', node.id);
1559
+ const wrapper = identify(element(grouped ? 'div' : 'label', nodeClasses(node, 'axiom-input')));
1304
1560
  if (grouped) {
1305
1561
  wrapper.setAttribute('role', 'group');
1306
1562
  }
@@ -1323,8 +1579,7 @@ export function createAxiomRuntime(options) {
1323
1579
  }
1324
1580
  const current = readLocation(node.binding.location, scope);
1325
1581
  const describedBy = [];
1326
- const control = element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control');
1327
- control.setAttribute('data-node', node.id);
1582
+ const control = identify(element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control'));
1328
1583
  if (descriptor.variant) {
1329
1584
  control.setAttribute('data-control', descriptor.variant);
1330
1585
  }
@@ -1420,7 +1675,7 @@ export function createAxiomRuntime(options) {
1420
1675
  mutate(() => mutations.set(node.binding.location, next, scope, context), context, failures);
1421
1676
  if (failures.length > 0) {
1422
1677
  settle(transaction, 'rolled-back');
1423
- inputErrors.set(node.id, failures[0].message);
1678
+ inputErrors.set(instance, failures[0].message);
1424
1679
  failures.forEach(report);
1425
1680
  }
1426
1681
  else {
@@ -1432,7 +1687,7 @@ export function createAxiomRuntime(options) {
1432
1687
  ];
1433
1688
  if (rejected.length > 0) {
1434
1689
  settle(transaction, 'rolled-back');
1435
- inputErrors.set(node.id, rejected[0].message);
1690
+ inputErrors.set(instance, rejected[0].message);
1436
1691
  rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
1437
1692
  report({
1438
1693
  code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
@@ -1444,10 +1699,10 @@ export function createAxiomRuntime(options) {
1444
1699
  }
1445
1700
  else {
1446
1701
  settle(transaction, 'committed');
1447
- inputErrors.delete(node.id);
1702
+ inputErrors.delete(instance);
1448
1703
  }
1449
1704
  }
1450
- focusedNodeId = node.id;
1705
+ focusedInstance = instance;
1451
1706
  focusedCaret = typeof source.selectionStart === 'number' ? source.selectionStart : null;
1452
1707
  renderApplication();
1453
1708
  };
@@ -1456,10 +1711,10 @@ export function createAxiomRuntime(options) {
1456
1711
  target.addEventListener('input', apply);
1457
1712
  target.addEventListener('change', apply);
1458
1713
  target.addEventListener('focus', () => {
1459
- focusedNodeId = node.id;
1714
+ focusedInstance = instance;
1460
1715
  });
1461
1716
  }
1462
- inputElements.set(node.id, control);
1717
+ inputElements.set(instance, control);
1463
1718
  wrapper.appendChild(control);
1464
1719
  if (presentation?.description) {
1465
1720
  const help = element('span', 'axiom-input-description axiom-text-caption');
@@ -1468,7 +1723,7 @@ export function createAxiomRuntime(options) {
1468
1723
  describedBy.push(`${controlId}-description`);
1469
1724
  wrapper.appendChild(help);
1470
1725
  }
1471
- const rejection = inputErrors.get(node.id);
1726
+ const rejection = inputErrors.get(instance);
1472
1727
  if (rejection) {
1473
1728
  // The refusal is related to the control it refused, not left floating.
1474
1729
  const error = element('span', 'axiom-input-description axiom-role-destructive axiom-text-caption');
@@ -1487,14 +1742,22 @@ export function createAxiomRuntime(options) {
1487
1742
  return wrapper;
1488
1743
  }
1489
1744
  case 'button': {
1490
- const button = element('button', nodeClasses(node, 'axiom-button'));
1491
- button.setAttribute('data-node', node.id);
1745
+ const submits = submitControls.get(node.id);
1746
+ const button = identify(element('button', nodeClasses(node, 'axiom-button', submits ? 'axiom-submit' : '')));
1492
1747
  button.setAttribute('type', 'button');
1493
1748
  const label = typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
1494
- if (appendIcon(button, presentation)) {
1749
+ if (presentation?.icon) {
1495
1750
  const caption = element('span', 'axiom-button-label');
1496
1751
  caption.textContent = label;
1497
- button.appendChild(caption);
1752
+ // Where the icon sits is a theme decision, not a per-button one.
1753
+ if (theme?.buttons?.iconPlacement === 'trailing') {
1754
+ button.appendChild(caption);
1755
+ appendIcon(button, presentation);
1756
+ }
1757
+ else {
1758
+ appendIcon(button, presentation);
1759
+ button.appendChild(caption);
1760
+ }
1498
1761
  }
1499
1762
  else {
1500
1763
  button.textContent = label;
@@ -1502,6 +1765,15 @@ export function createAxiomRuntime(options) {
1502
1765
  if (presentation?.accessibleLabel) {
1503
1766
  button.setAttribute('aria-label', presentation.accessibleLabel);
1504
1767
  }
1768
+ const reporting = reportingRegionFor(node.actionId, path);
1769
+ if (reporting) {
1770
+ button.setAttribute('aria-describedby', reporting);
1771
+ }
1772
+ if (submits) {
1773
+ // Native form submission runs the action; a click handler here would run it twice.
1774
+ button.setAttribute('type', 'submit');
1775
+ return button;
1776
+ }
1505
1777
  button.addEventListener('click', (event) => {
1506
1778
  event.preventDefault?.();
1507
1779
  const args = {};
@@ -1512,11 +1784,37 @@ export function createAxiomRuntime(options) {
1512
1784
  });
1513
1785
  return button;
1514
1786
  }
1787
+ case 'diagnostic': {
1788
+ const region = identify(element('div', nodeClasses(node, 'axiom-diagnostic')));
1789
+ region.setAttribute('id', `axiom-diagnostic-${instance}`);
1790
+ const ariaRole = ariaRoleFor(presentation?.uxRole);
1791
+ if (ariaRole) {
1792
+ region.setAttribute('role', ariaRole);
1793
+ }
1794
+ if (presentation?.accessibleLabel) {
1795
+ region.setAttribute('aria-label', presentation.accessibleLabel);
1796
+ }
1797
+ const reported = presentedDiagnostics(node);
1798
+ if (reported.length === 0) {
1799
+ // Nothing to report: the region renders empty rather than disappearing, so the
1800
+ // relationship an initiating control declares stays resolvable.
1801
+ region.setAttribute('data-empty', 'true');
1802
+ return region;
1803
+ }
1804
+ appendIcon(region, presentation);
1805
+ for (const diagnostic of reported) {
1806
+ const entry = element('span', 'axiom-diagnostic-entry axiom-text-body');
1807
+ entry.setAttribute('data-code', diagnostic.code);
1808
+ // The wording is the structured diagnostic's own; the renderer invents none.
1809
+ entry.textContent = diagnostic.message;
1810
+ region.appendChild(entry);
1811
+ }
1812
+ return region;
1813
+ }
1515
1814
  case 'conditional': {
1516
- const container = element('div', nodeClasses(node, 'axiom-conditional'));
1517
- container.setAttribute('data-node', node.id);
1815
+ const container = identify(element('div', nodeClasses(node, 'axiom-conditional')));
1518
1816
  const branch = toBoolean(evaluate(node.condition, scope)) ? node.whenTrue : node.whenFalse ?? [];
1519
- renderChildren(branch, scope, container);
1817
+ renderChildren(branch, scope, container, path);
1520
1818
  return container;
1521
1819
  }
1522
1820
  default:
@@ -1537,15 +1835,15 @@ export function createAxiomRuntime(options) {
1537
1835
  rootElement.replaceChildren(missing);
1538
1836
  return;
1539
1837
  }
1540
- const view = renderNode(activeRoute.route.viewId, scope);
1838
+ const view = renderNode(activeRoute.route.viewId, scope, ROOT_INSTANCE);
1541
1839
  rootElement.replaceChildren(...(view ? [view] : []));
1542
1840
  restoreFocus();
1543
1841
  }
1544
1842
  function restoreFocus() {
1545
- if (!focusedNodeId) {
1843
+ if (!focusedInstance) {
1546
1844
  return;
1547
1845
  }
1548
- const control = inputElements.get(focusedNodeId);
1846
+ const control = inputElements.get(focusedInstance);
1549
1847
  if (!control) {
1550
1848
  return;
1551
1849
  }
@@ -1570,6 +1868,9 @@ export function createAxiomRuntime(options) {
1570
1868
  host.onPathChange(() => {
1571
1869
  activeRoute = matchRoute(host.getPath());
1572
1870
  derivedCache.clear();
1871
+ // Diagnostics are about the screen that produced them.
1872
+ actionOutcomes.clear();
1873
+ inputErrors.clear();
1573
1874
  renderApplication();
1574
1875
  });
1575
1876
  syncRoute();
@@ -1604,6 +1905,13 @@ export function createAxiomRuntime(options) {
1604
1905
  },
1605
1906
  clearDiagnostics() {
1606
1907
  diagnostics.length = 0;
1908
+ actionOutcomes.clear();
1909
+ inputErrors.clear();
1910
+ renderApplication();
1911
+ },
1912
+ getActionOutcome(id) {
1913
+ const record = actionOutcomes.get(id);
1914
+ return record ? { ...record, diagnostics: record.diagnostics.map((d) => ({ ...d })) } : undefined;
1607
1915
  },
1608
1916
  getMutationLog() {
1609
1917
  return mutationLog.map((entry) => ({ ...entry }));
@@ -1611,6 +1919,25 @@ export function createAxiomRuntime(options) {
1611
1919
  registerNativeOperation(implementationId, implementation) {
1612
1920
  natives.set(implementationId, implementation);
1613
1921
  },
1922
+ async invokeActionAsync(id, args = {}) {
1923
+ const result = runAction(id, args);
1924
+ if (!result.pending) {
1925
+ return result;
1926
+ }
1927
+ return (await pending.get(id)) ?? result;
1928
+ },
1929
+ async syncAuthoritativeState() {
1930
+ if (!remote?.snapshot) {
1931
+ return;
1932
+ }
1933
+ const snapshot = await remote.snapshot();
1934
+ applyAuthoritative(snapshot.states ?? {});
1935
+ renderApplication();
1936
+ },
1937
+ evaluate(expression) {
1938
+ const outcome = tryEvaluate(expression, rootScope(), {});
1939
+ return outcome.ok ? { ok: true, value: outcome.value } : { ok: false, diagnostic: outcome.diagnostic };
1940
+ },
1614
1941
  };
1615
1942
  }
1616
1943
  /** Builds a host bound to the browser globals. Used by generated pages. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.5.0-alpha.1",
3
+ "version": "0.6.0-alpha.1",
4
4
  "description": "Domain-independent runtime that executes an Axiom application graph.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@cynodia/axiom-core": "0.5.0-alpha.1"
34
+ "@cynodia/axiom-core": "0.6.0-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",