@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 +12 -2
- package/dist/presentation-classes.d.ts +7 -4
- package/dist/presentation-classes.js +22 -15
- package/dist/runtime.d.ts +35 -1
- package/dist/runtime.js +256 -63
- package/package.json +2 -2
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
|
|
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
|
|
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,
|
|
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.
|
|
13
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
119
|
-
*
|
|
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(
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1252
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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(
|
|
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-${
|
|
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(
|
|
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(
|
|
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(
|
|
1587
|
+
inputErrors.delete(instance);
|
|
1448
1588
|
}
|
|
1449
1589
|
}
|
|
1450
|
-
|
|
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
|
-
|
|
1599
|
+
focusedInstance = instance;
|
|
1460
1600
|
});
|
|
1461
1601
|
}
|
|
1462
|
-
inputElements.set(
|
|
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(
|
|
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
|
|
1491
|
-
button
|
|
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 (
|
|
1634
|
+
if (presentation?.icon) {
|
|
1495
1635
|
const caption = element('span', 'axiom-button-label');
|
|
1496
1636
|
caption.textContent = label;
|
|
1497
|
-
button.
|
|
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 (!
|
|
1728
|
+
if (!focusedInstance) {
|
|
1546
1729
|
return;
|
|
1547
1730
|
}
|
|
1548
|
-
const control = inputElements.get(
|
|
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.
|
|
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.
|
|
34
|
+
"@cynodia/axiom-core": "0.5.2-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|