@cynodia/axiom-runtime 0.5.0-alpha.1 → 0.5.2-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
@@ -31,6 +31,10 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
31
31
  readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
32
32
  };
33
33
  export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
34
+ /**
35
+ * A structured runtime failure. Match on `code` and read `details`; the `message` is for
36
+ * people and is not a stable contract.
37
+ */
34
38
  export interface RuntimeDiagnostic {
35
39
  code: RuntimeDiagnosticCode;
36
40
  message: string;
@@ -49,13 +53,30 @@ export interface ActionResult {
49
53
  ok: boolean;
50
54
  diagnostics: RuntimeDiagnostic[];
51
55
  }
56
+ /**
57
+ * The outcome of an action's most recent invocation.
58
+ *
59
+ * `ok` and `cancelled` both carry no diagnostics, so a `diagnostic` UI node presenting
60
+ * this action shows nothing after either. They are distinguished here because the
61
+ * difference matters to an agent: `cancelled` means a person declined the confirmation,
62
+ * not that the action was refused.
63
+ */
64
+ export interface ActionOutcome {
65
+ actionId: NodeId;
66
+ outcome: 'ok' | 'failed' | 'cancelled';
67
+ diagnostics: RuntimeDiagnostic[];
68
+ }
52
69
  export interface RouteMatch {
53
70
  route: CompiledRoute;
54
71
  /** Parameter values keyed by route parameter id. */
55
72
  parameters: Record<string, string>;
56
73
  }
57
74
  export type NativeImplementation = (inputs: Record<string, unknown>) => unknown;
58
- /** When constraints are evaluated after an input writes to its location. */
75
+ /**
76
+ * When entity constraints are evaluated after an input writes to its location.
77
+ * `'deferred'` turns the per-keystroke check off entirely, leaving validity to the next
78
+ * action. Transition constraints are unaffected by this setting and always apply.
79
+ */
59
80
  export type InputValidationMode = 'immediate' | 'deferred';
60
81
  export interface AxiomRuntimeOptions {
61
82
  ir: ApplicationIR;
@@ -69,6 +90,7 @@ export interface AxiomRuntimeOptions {
69
90
  export interface AxiomRuntime {
70
91
  start(): void;
71
92
  render(): void;
93
+ /** A deep clone of the value. Derived state is recomputed. */
72
94
  getState(id: NodeId): unknown;
73
95
  /**
74
96
  * Replaces a state value outright, for hosts, tests and seeding.
@@ -78,12 +100,24 @@ export interface AxiomRuntime {
78
100
  * belongs in actions and input bindings, which are governed.
79
101
  */
80
102
  hydrateState(id: NodeId, value: unknown): void;
103
+ /**
104
+ * Runs an action as a transaction and returns the diagnostics **of that invocation**,
105
+ * so nothing has to diff global history. Either every mutation commits or every one is
106
+ * rolled back.
107
+ */
81
108
  invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
82
109
  navigate(path: string): void;
83
110
  currentRoute(): RouteMatch | null;
84
111
  /** Every diagnostic reported so far. Per-invocation results carry their own. */
85
112
  diagnostics(): RuntimeDiagnostic[];
113
+ /** Clears the running log **and** every recorded action outcome. */
86
114
  clearDiagnostics(): void;
115
+ /**
116
+ * The outcome of this action's most recent invocation, or `undefined` if it has not been
117
+ * invoked since the last clear or route change. This is what a `diagnostic` UI node
118
+ * presents.
119
+ */
120
+ getActionOutcome(id: NodeId): ActionOutcome | undefined;
87
121
  /** Every mutation this runtime has applied, in order, with its semantic location. */
88
122
  getMutationLog(): MutationLogEntry[];
89
123
  registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
package/dist/runtime.js CHANGED
@@ -68,7 +68,7 @@ function defaultForType(type) {
68
68
  */
69
69
  function requireCollection(value, operator) {
70
70
  if (!Array.isArray(value)) {
71
- throw new ExpressionEvaluationError(`${operator} expects a collection but received ${describeValue(value)}`, { operator, received: value });
71
+ throw new ExpressionEvaluationError(`${operator} expects a collection but received ${describeValue(value)}`, { collectionOperator: operator, received: value });
72
72
  }
73
73
  return value;
74
74
  }
@@ -91,8 +91,9 @@ export function createAxiomRuntime(options) {
91
91
  const derivedCache = new Map();
92
92
  const natives = new Map(Object.entries(options.nativeOperations ?? {}));
93
93
  const diagnostics = [];
94
+ /** Rendered controls, keyed by render instance — not by node id. */
94
95
  const inputElements = new Map();
95
- let focusedNodeId = null;
96
+ let focusedInstance = null;
96
97
  let focusedCaret = null;
97
98
  let started = false;
98
99
  let transactionCounter = 0;
@@ -100,12 +101,74 @@ export function createAxiomRuntime(options) {
100
101
  const inputValidation = options.inputValidation ?? 'immediate';
101
102
  const theme = ir.theme;
102
103
  const locale = theme?.locale ?? 'en-US';
103
- /** Messages for inputs whose last write was refused, so a control can say so. */
104
+ /**
105
+ * Messages for inputs whose last write was refused, so a control can say so. Keyed by
106
+ * render instance: refusing a write in one row must not mark another row invalid.
107
+ */
104
108
  const inputErrors = new Map();
109
+ /**
110
+ * The most recent outcome of each action, which is what a `diagnostic` node presents.
111
+ * Ephemeral runtime state: it is replaced by the next invocation of the same action, and
112
+ * cleared by `clearDiagnostics()` and by navigating to another route.
113
+ */
114
+ const actionOutcomes = new Map();
115
+ /**
116
+ * Buttons that are their form's declared submit control, and the action each submits.
117
+ * Such a button carries native submit behaviour instead of its own click handler, so a
118
+ * click runs the action exactly once.
119
+ */
120
+ const submitControls = new Map();
121
+ for (const node of Object.values(ir.uiNodes)) {
122
+ if (node.kind !== 'form' || !node.submitButtonId) {
123
+ continue;
124
+ }
125
+ const button = ir.uiNodes[node.submitButtonId];
126
+ if (button?.kind === 'button') {
127
+ submitControls.set(node.submitButtonId, {
128
+ formId: node.id,
129
+ actionId: node.submitActionId ?? button.actionId,
130
+ });
131
+ }
132
+ }
133
+ /** Diagnostic regions, by the action they report. Indexed once. */
134
+ const diagnosticRegions = new Map();
135
+ for (const node of Object.values(ir.uiNodes)) {
136
+ if (node.kind === 'diagnostic') {
137
+ diagnosticRegions.set(node.actionId, [...(diagnosticRegions.get(node.actionId) ?? []), node.id]);
138
+ }
139
+ }
140
+ function recordOutcome(actionId, outcome, diagnostics) {
141
+ actionOutcomes.set(actionId, {
142
+ actionId: actionId,
143
+ outcome,
144
+ // Only what this invocation reported, and only what a region could present.
145
+ diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic })),
146
+ });
147
+ }
105
148
  /** Presentation, already resolved by the compiler. The renderer decides nothing. */
106
149
  function presentationOf(id) {
107
150
  return ir.presentation?.[id];
108
151
  }
152
+ /**
153
+ * Where a rendered element sits: the repeat instances enclosing it, outermost first.
154
+ *
155
+ * A UI node inside a `repeat` is rendered once per member, so `NodeId` alone cannot
156
+ * identify a rendered element. The graph still holds one semantic node; this is the
157
+ * runtime presentation identity that goes with it, and the two are deliberately
158
+ * distinct — `data-node` carries the first, `data-instance` the second.
159
+ */
160
+ const ROOT_INSTANCE = [];
161
+ /** Everything a renderer-generated id, relationship or lookup is keyed by. */
162
+ function instanceKey(nodeId, path) {
163
+ return path.length === 0 ? nodeId : `${nodeId}--${path.join('--')}`;
164
+ }
165
+ /** A DOM-safe fragment of an item's identity. */
166
+ function identityFragment(value, index) {
167
+ const text = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
168
+ const safe = text.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
169
+ // An absent or unusable identity falls back to a deterministic iteration index.
170
+ return safe === '' ? `i${index}` : safe;
171
+ }
109
172
  const statesById = new Map(ir.states.map((state) => [state.id, state]));
110
173
  const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
111
174
  const parameterTypes = new Map();
@@ -274,11 +337,25 @@ export function createAxiomRuntime(options) {
274
337
  }
275
338
  }
276
339
  }
340
+ /**
341
+ * Classifies an evaluation failure. The code says what kind of failure it was, so an
342
+ * agent can distinguish an identifier that did not resolve from a value of the wrong
343
+ * shape without reading the message.
344
+ */
345
+ function evaluationFailureCode(details) {
346
+ if (details.targetId !== undefined) {
347
+ return RUNTIME_DIAGNOSTIC_CODES.UNRESOLVED_REFERENCE;
348
+ }
349
+ if (details.kind !== undefined || details.operator !== undefined || details.function !== undefined) {
350
+ return RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION;
351
+ }
352
+ return RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED;
353
+ }
277
354
  /** Turns an evaluation failure into a diagnostic instead of letting it escape. */
278
355
  function evaluationFailure(error, context = {}) {
279
356
  if (error instanceof ExpressionEvaluationError) {
280
357
  return {
281
- code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
358
+ code: evaluationFailureCode(error.details),
282
359
  message: error.message,
283
360
  severity: 'error',
284
361
  ...context,
@@ -303,7 +380,9 @@ export function createAxiomRuntime(options) {
303
380
  }
304
381
  catch (error) {
305
382
  const failure = {
306
- code: error instanceof LocationResolutionError ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED : 'MUTATION_FAILED',
383
+ code: error instanceof LocationResolutionError
384
+ ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED
385
+ : RUNTIME_DIAGNOSTIC_CODES.MUTATION_FAILED,
307
386
  message: error instanceof Error ? error.message : String(error),
308
387
  severity: 'error',
309
388
  ...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
@@ -852,8 +931,25 @@ export function createAxiomRuntime(options) {
852
931
  }
853
932
  }
854
933
  function runAction(actionId, args = {}) {
855
- return collecting((collected) => runActionCollecting(actionId, args, collected));
934
+ return collecting((collected) => {
935
+ const started = collected.length;
936
+ const result = runActionCollecting(actionId, args, collected);
937
+ if (ir.actions[actionId]) {
938
+ // The record is this invocation's own diagnostics, so a later invocation of another
939
+ // action can never appear to belong to this one.
940
+ recordOutcome(actionId, result.ok ? 'ok' : cancelled ? 'cancelled' : 'failed', result.ok ? [] : collected.slice(started));
941
+ }
942
+ cancelled = false;
943
+ // Any invocation can change what a diagnostic region presents, including one refused
944
+ // before a transaction was ever opened. Render once, at the outermost invocation.
945
+ if (transactions.currentId() === undefined) {
946
+ renderApplication();
947
+ }
948
+ return result;
949
+ });
856
950
  }
951
+ /** Set when the most recent invocation stopped because a confirmation was declined. */
952
+ let cancelled = false;
857
953
  function runActionCollecting(actionId, args, collected) {
858
954
  const action = ir.actions[actionId];
859
955
  if (!action) {
@@ -908,6 +1004,8 @@ export function createAxiomRuntime(options) {
908
1004
  }
909
1005
  if (action.requiresConfirmation) {
910
1006
  if (!askForConfirmation(action)) {
1007
+ // Declining a confirmation is not a refusal: it reports nothing.
1008
+ cancelled = true;
911
1009
  return { ok: false, diagnostics: [...collected] };
912
1010
  }
913
1011
  }
@@ -952,11 +1050,9 @@ export function createAxiomRuntime(options) {
952
1050
  report({ ...violation, actionId: action.id, transactionId: transaction.id });
953
1051
  }
954
1052
  }
955
- renderApplication();
956
1053
  return { ok: false, diagnostics: [...collected] };
957
1054
  }
958
1055
  settle(transaction, 'committed');
959
- renderApplication();
960
1056
  return { ok: true, diagnostics: [...collected] };
961
1057
  }
962
1058
  // ------------------------------------------------------------------ routing
@@ -1004,7 +1100,13 @@ export function createAxiomRuntime(options) {
1004
1100
  syncRoute();
1005
1101
  }
1006
1102
  function syncRoute() {
1103
+ const previous = activeRoute?.route.id;
1007
1104
  activeRoute = matchRoute(host.getPath());
1105
+ if (previous !== undefined && previous !== activeRoute?.route.id) {
1106
+ // Diagnostics are about the screen that produced them.
1107
+ actionOutcomes.clear();
1108
+ inputErrors.clear();
1109
+ }
1008
1110
  derivedCache.clear();
1009
1111
  renderApplication();
1010
1112
  }
@@ -1060,9 +1162,36 @@ export function createAxiomRuntime(options) {
1060
1162
  };
1061
1163
  return host.confirmRequest(request);
1062
1164
  }
1063
- function renderChildren(ids, scope, parent) {
1165
+ /**
1166
+ * The diagnostics a region presents: those of its action's most recent invocation, at or
1167
+ * above the region's own severity.
1168
+ */
1169
+ function presentedDiagnostics(node) {
1170
+ const record = actionOutcomes.get(node.actionId);
1171
+ if (!record) {
1172
+ return [];
1173
+ }
1174
+ const minimum = node.severity ?? 'error';
1175
+ return record.diagnostics.filter((diagnostic) => minimum === 'warning' || diagnostic.severity === 'error');
1176
+ }
1177
+ /** Whether any region currently reports something about this action. */
1178
+ function reportingRegionFor(actionId, path) {
1179
+ if (path.length > 0) {
1180
+ // A region reports one action, not one row; relating a repeated control to it would
1181
+ // be guesswork. Rows report through their own input diagnostics instead.
1182
+ return null;
1183
+ }
1184
+ for (const regionId of diagnosticRegions.get(actionId) ?? []) {
1185
+ const region = ir.uiNodes[regionId];
1186
+ if (region?.kind === 'diagnostic' && presentedDiagnostics(region).length > 0) {
1187
+ return `axiom-diagnostic-${regionId}`;
1188
+ }
1189
+ }
1190
+ return null;
1191
+ }
1192
+ function renderChildren(ids, scope, parent, path) {
1064
1193
  for (const id of ids) {
1065
- const child = renderNode(id, scope);
1194
+ const child = renderNode(id, scope, path);
1066
1195
  if (child) {
1067
1196
  parent.appendChild(child);
1068
1197
  }
@@ -1169,16 +1298,16 @@ export function createAxiomRuntime(options) {
1169
1298
  }
1170
1299
  return raw;
1171
1300
  }
1172
- function renderNode(id, scope) {
1301
+ function renderNode(id, scope, path) {
1173
1302
  try {
1174
- return renderNodeUnguarded(id, scope);
1303
+ return renderNodeUnguarded(id, scope, path);
1175
1304
  }
1176
1305
  catch (error) {
1177
1306
  report(evaluationFailure(error, { nodeId: id }));
1178
1307
  return null;
1179
1308
  }
1180
1309
  }
1181
- function renderNodeUnguarded(id, scope) {
1310
+ function renderNodeUnguarded(id, scope, path) {
1182
1311
  const node = ir.uiNodes[id];
1183
1312
  if (!node) {
1184
1313
  report({
@@ -1193,18 +1322,25 @@ export function createAxiomRuntime(options) {
1193
1322
  return null;
1194
1323
  }
1195
1324
  const presentation = presentationOf(node.id);
1325
+ const instance = instanceKey(node.id, path);
1326
+ /** `data-node` is the semantic node; `data-instance` is this rendering of it. */
1327
+ const identify = (target) => {
1328
+ target.setAttribute('data-node', node.id);
1329
+ if (path.length > 0) {
1330
+ target.setAttribute('data-instance', instance);
1331
+ }
1332
+ return target;
1333
+ };
1196
1334
  switch (node.kind) {
1197
1335
  case 'view': {
1198
- const container = element('div', nodeClasses(node, 'axiom-view'));
1199
- container.setAttribute('data-node', node.id);
1200
- renderChildren(node.children, scope, container);
1336
+ const container = identify(element('div', nodeClasses(node, 'axiom-view')));
1337
+ renderChildren(node.children, scope, container, path);
1201
1338
  return container;
1202
1339
  }
1203
1340
  case 'container': {
1204
1341
  // A UX role that names a region of the page becomes the element that means it, so
1205
1342
  // 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);
1343
+ const container = identify(element(landmarkTag(presentation?.uxRole) ?? 'div', nodeClasses(node, 'axiom-container')));
1208
1344
  const ariaRole = ariaRoleFor(presentation?.uxRole);
1209
1345
  if (ariaRole) {
1210
1346
  container.setAttribute('role', ariaRole);
@@ -1213,12 +1349,11 @@ export function createAxiomRuntime(options) {
1213
1349
  container.setAttribute('aria-label', presentation.accessibleLabel);
1214
1350
  }
1215
1351
  appendIcon(container, presentation);
1216
- renderChildren(node.children, scope, container);
1352
+ renderChildren(node.children, scope, container, path);
1217
1353
  return container;
1218
1354
  }
1219
1355
  case 'text': {
1220
- const text = element(headingTag(presentation?.textRole) ?? 'span', nodeClasses(node, 'axiom-text'));
1221
- text.setAttribute('data-node', node.id);
1356
+ const text = identify(element(headingTag(presentation) ?? 'span', nodeClasses(node, 'axiom-text')));
1222
1357
  // A status role announces itself whatever kind of node carries it.
1223
1358
  const textRole = ariaRoleFor(presentation?.uxRole);
1224
1359
  if (textRole) {
@@ -1240,25 +1375,27 @@ export function createAxiomRuntime(options) {
1240
1375
  return text;
1241
1376
  }
1242
1377
  case 'repeat': {
1243
- const container = element('div', nodeClasses(node, 'axiom-repeat'));
1244
- container.setAttribute('data-node', node.id);
1378
+ const container = identify(element('div', nodeClasses(node, 'axiom-repeat')));
1245
1379
  const source = evaluate(node.source, scope);
1246
1380
  const items = Array.isArray(source) ? source : [];
1247
1381
  if (items.length === 0 && node.emptyTemplateId) {
1248
- renderChildren([node.emptyTemplateId], scope, container);
1382
+ renderChildren([node.emptyTemplateId], scope, container, path);
1249
1383
  return container;
1250
1384
  }
1251
- for (const item of items) {
1252
- const child = renderNode(node.templateId, childScope(scope, node.id, item));
1385
+ // Each member gets its own render instance, preferring its semantic identity so
1386
+ // the identity survives reordering. Nested repeats compose rather than collide.
1387
+ const identityFieldId = ir.repeatIdentityFields?.[node.id];
1388
+ items.forEach((item, index) => {
1389
+ const identity = identityFieldId && isRecord(item) ? item[identityFieldId] : undefined;
1390
+ const child = renderNode(node.templateId, childScope(scope, node.id, item), [...path, identityFragment(identity, index)]);
1253
1391
  if (child) {
1254
1392
  container.appendChild(child);
1255
1393
  }
1256
- }
1394
+ });
1257
1395
  return container;
1258
1396
  }
1259
1397
  case 'field-display': {
1260
- const container = element('div', nodeClasses(node, 'axiom-field'));
1261
- container.setAttribute('data-node', node.id);
1398
+ const container = identify(element('div', nodeClasses(node, 'axiom-field')));
1262
1399
  const field = fieldOf(node.fieldId);
1263
1400
  if (node.label ?? field?.name) {
1264
1401
  const label = element('span', 'axiom-field-label');
@@ -1276,20 +1413,25 @@ export function createAxiomRuntime(options) {
1276
1413
  return container;
1277
1414
  }
1278
1415
  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;
1416
+ const form = identify(element('form', nodeClasses(node, 'axiom-form')));
1417
+ renderChildren(node.children, scope, form, path);
1418
+ const declaredControl = node.submitButtonId
1419
+ ? submitControls.get(node.submitButtonId)
1420
+ : undefined;
1421
+ const submitActionId = declaredControl?.actionId ?? node.submitActionId;
1422
+ if (submitActionId) {
1423
+ if (!declaredControl) {
1424
+ // The simple form: the renderer supplies the button.
1425
+ 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');
1426
+ const submit = element('button', 'axiom-submit axiom-button axiom-role-primary axiom-emphasis-strong axiom-ux-primary-action');
1427
+ submit.setAttribute('type', 'submit');
1428
+ submit.textContent = node.submitLabel ?? 'Submit';
1429
+ actions.appendChild(submit);
1430
+ form.appendChild(actions);
1431
+ }
1290
1432
  form.addEventListener('submit', (event) => {
1291
1433
  event.preventDefault?.();
1292
- runAction(actionId);
1434
+ runAction(submitActionId);
1293
1435
  });
1294
1436
  }
1295
1437
  return form;
@@ -1297,10 +1439,9 @@ export function createAxiomRuntime(options) {
1297
1439
  case 'input': {
1298
1440
  const descriptor = resolveInputTag(node);
1299
1441
  const grouped = descriptor.tag === 'radio-group';
1300
- const controlId = `axiom-control-${node.id}`;
1442
+ const controlId = `axiom-control-${instance}`;
1301
1443
  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);
1444
+ const wrapper = identify(element(grouped ? 'div' : 'label', nodeClasses(node, 'axiom-input')));
1304
1445
  if (grouped) {
1305
1446
  wrapper.setAttribute('role', 'group');
1306
1447
  }
@@ -1323,8 +1464,7 @@ export function createAxiomRuntime(options) {
1323
1464
  }
1324
1465
  const current = readLocation(node.binding.location, scope);
1325
1466
  const describedBy = [];
1326
- const control = element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control');
1327
- control.setAttribute('data-node', node.id);
1467
+ const control = identify(element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control'));
1328
1468
  if (descriptor.variant) {
1329
1469
  control.setAttribute('data-control', descriptor.variant);
1330
1470
  }
@@ -1420,7 +1560,7 @@ export function createAxiomRuntime(options) {
1420
1560
  mutate(() => mutations.set(node.binding.location, next, scope, context), context, failures);
1421
1561
  if (failures.length > 0) {
1422
1562
  settle(transaction, 'rolled-back');
1423
- inputErrors.set(node.id, failures[0].message);
1563
+ inputErrors.set(instance, failures[0].message);
1424
1564
  failures.forEach(report);
1425
1565
  }
1426
1566
  else {
@@ -1432,7 +1572,7 @@ export function createAxiomRuntime(options) {
1432
1572
  ];
1433
1573
  if (rejected.length > 0) {
1434
1574
  settle(transaction, 'rolled-back');
1435
- inputErrors.set(node.id, rejected[0].message);
1575
+ inputErrors.set(instance, rejected[0].message);
1436
1576
  rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
1437
1577
  report({
1438
1578
  code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
@@ -1444,10 +1584,10 @@ export function createAxiomRuntime(options) {
1444
1584
  }
1445
1585
  else {
1446
1586
  settle(transaction, 'committed');
1447
- inputErrors.delete(node.id);
1587
+ inputErrors.delete(instance);
1448
1588
  }
1449
1589
  }
1450
- focusedNodeId = node.id;
1590
+ focusedInstance = instance;
1451
1591
  focusedCaret = typeof source.selectionStart === 'number' ? source.selectionStart : null;
1452
1592
  renderApplication();
1453
1593
  };
@@ -1456,10 +1596,10 @@ export function createAxiomRuntime(options) {
1456
1596
  target.addEventListener('input', apply);
1457
1597
  target.addEventListener('change', apply);
1458
1598
  target.addEventListener('focus', () => {
1459
- focusedNodeId = node.id;
1599
+ focusedInstance = instance;
1460
1600
  });
1461
1601
  }
1462
- inputElements.set(node.id, control);
1602
+ inputElements.set(instance, control);
1463
1603
  wrapper.appendChild(control);
1464
1604
  if (presentation?.description) {
1465
1605
  const help = element('span', 'axiom-input-description axiom-text-caption');
@@ -1468,7 +1608,7 @@ export function createAxiomRuntime(options) {
1468
1608
  describedBy.push(`${controlId}-description`);
1469
1609
  wrapper.appendChild(help);
1470
1610
  }
1471
- const rejection = inputErrors.get(node.id);
1611
+ const rejection = inputErrors.get(instance);
1472
1612
  if (rejection) {
1473
1613
  // The refusal is related to the control it refused, not left floating.
1474
1614
  const error = element('span', 'axiom-input-description axiom-role-destructive axiom-text-caption');
@@ -1487,14 +1627,22 @@ export function createAxiomRuntime(options) {
1487
1627
  return wrapper;
1488
1628
  }
1489
1629
  case 'button': {
1490
- const button = element('button', nodeClasses(node, 'axiom-button'));
1491
- button.setAttribute('data-node', node.id);
1630
+ const submits = submitControls.get(node.id);
1631
+ const button = identify(element('button', nodeClasses(node, 'axiom-button', submits ? 'axiom-submit' : '')));
1492
1632
  button.setAttribute('type', 'button');
1493
1633
  const label = typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
1494
- if (appendIcon(button, presentation)) {
1634
+ if (presentation?.icon) {
1495
1635
  const caption = element('span', 'axiom-button-label');
1496
1636
  caption.textContent = label;
1497
- button.appendChild(caption);
1637
+ // Where the icon sits is a theme decision, not a per-button one.
1638
+ if (theme?.buttons?.iconPlacement === 'trailing') {
1639
+ button.appendChild(caption);
1640
+ appendIcon(button, presentation);
1641
+ }
1642
+ else {
1643
+ appendIcon(button, presentation);
1644
+ button.appendChild(caption);
1645
+ }
1498
1646
  }
1499
1647
  else {
1500
1648
  button.textContent = label;
@@ -1502,6 +1650,15 @@ export function createAxiomRuntime(options) {
1502
1650
  if (presentation?.accessibleLabel) {
1503
1651
  button.setAttribute('aria-label', presentation.accessibleLabel);
1504
1652
  }
1653
+ const reporting = reportingRegionFor(node.actionId, path);
1654
+ if (reporting) {
1655
+ button.setAttribute('aria-describedby', reporting);
1656
+ }
1657
+ if (submits) {
1658
+ // Native form submission runs the action; a click handler here would run it twice.
1659
+ button.setAttribute('type', 'submit');
1660
+ return button;
1661
+ }
1505
1662
  button.addEventListener('click', (event) => {
1506
1663
  event.preventDefault?.();
1507
1664
  const args = {};
@@ -1512,11 +1669,37 @@ export function createAxiomRuntime(options) {
1512
1669
  });
1513
1670
  return button;
1514
1671
  }
1672
+ case 'diagnostic': {
1673
+ const region = identify(element('div', nodeClasses(node, 'axiom-diagnostic')));
1674
+ region.setAttribute('id', `axiom-diagnostic-${instance}`);
1675
+ const ariaRole = ariaRoleFor(presentation?.uxRole);
1676
+ if (ariaRole) {
1677
+ region.setAttribute('role', ariaRole);
1678
+ }
1679
+ if (presentation?.accessibleLabel) {
1680
+ region.setAttribute('aria-label', presentation.accessibleLabel);
1681
+ }
1682
+ const reported = presentedDiagnostics(node);
1683
+ if (reported.length === 0) {
1684
+ // Nothing to report: the region renders empty rather than disappearing, so the
1685
+ // relationship an initiating control declares stays resolvable.
1686
+ region.setAttribute('data-empty', 'true');
1687
+ return region;
1688
+ }
1689
+ appendIcon(region, presentation);
1690
+ for (const diagnostic of reported) {
1691
+ const entry = element('span', 'axiom-diagnostic-entry axiom-text-body');
1692
+ entry.setAttribute('data-code', diagnostic.code);
1693
+ // The wording is the structured diagnostic's own; the renderer invents none.
1694
+ entry.textContent = diagnostic.message;
1695
+ region.appendChild(entry);
1696
+ }
1697
+ return region;
1698
+ }
1515
1699
  case 'conditional': {
1516
- const container = element('div', nodeClasses(node, 'axiom-conditional'));
1517
- container.setAttribute('data-node', node.id);
1700
+ const container = identify(element('div', nodeClasses(node, 'axiom-conditional')));
1518
1701
  const branch = toBoolean(evaluate(node.condition, scope)) ? node.whenTrue : node.whenFalse ?? [];
1519
- renderChildren(branch, scope, container);
1702
+ renderChildren(branch, scope, container, path);
1520
1703
  return container;
1521
1704
  }
1522
1705
  default:
@@ -1537,15 +1720,15 @@ export function createAxiomRuntime(options) {
1537
1720
  rootElement.replaceChildren(missing);
1538
1721
  return;
1539
1722
  }
1540
- const view = renderNode(activeRoute.route.viewId, scope);
1723
+ const view = renderNode(activeRoute.route.viewId, scope, ROOT_INSTANCE);
1541
1724
  rootElement.replaceChildren(...(view ? [view] : []));
1542
1725
  restoreFocus();
1543
1726
  }
1544
1727
  function restoreFocus() {
1545
- if (!focusedNodeId) {
1728
+ if (!focusedInstance) {
1546
1729
  return;
1547
1730
  }
1548
- const control = inputElements.get(focusedNodeId);
1731
+ const control = inputElements.get(focusedInstance);
1549
1732
  if (!control) {
1550
1733
  return;
1551
1734
  }
@@ -1570,6 +1753,9 @@ export function createAxiomRuntime(options) {
1570
1753
  host.onPathChange(() => {
1571
1754
  activeRoute = matchRoute(host.getPath());
1572
1755
  derivedCache.clear();
1756
+ // Diagnostics are about the screen that produced them.
1757
+ actionOutcomes.clear();
1758
+ inputErrors.clear();
1573
1759
  renderApplication();
1574
1760
  });
1575
1761
  syncRoute();
@@ -1604,6 +1790,13 @@ export function createAxiomRuntime(options) {
1604
1790
  },
1605
1791
  clearDiagnostics() {
1606
1792
  diagnostics.length = 0;
1793
+ actionOutcomes.clear();
1794
+ inputErrors.clear();
1795
+ renderApplication();
1796
+ },
1797
+ getActionOutcome(id) {
1798
+ const record = actionOutcomes.get(id);
1799
+ return record ? { ...record, diagnostics: record.diagnostics.map((d) => ({ ...d })) } : undefined;
1607
1800
  },
1608
1801
  getMutationLog() {
1609
1802
  return mutationLog.map((entry) => ({ ...entry }));
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.5.2-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.5.2-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",