@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 +12 -2
- package/dist/presentation-classes.d.ts +7 -4
- package/dist/presentation-classes.js +22 -15
- package/dist/runtime.d.ts +97 -2
- package/dist/runtime.js +394 -67
- 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
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1252
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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(
|
|
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-${
|
|
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(
|
|
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(
|
|
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(
|
|
1702
|
+
inputErrors.delete(instance);
|
|
1448
1703
|
}
|
|
1449
1704
|
}
|
|
1450
|
-
|
|
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
|
-
|
|
1714
|
+
focusedInstance = instance;
|
|
1460
1715
|
});
|
|
1461
1716
|
}
|
|
1462
|
-
inputElements.set(
|
|
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(
|
|
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
|
|
1491
|
-
button
|
|
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 (
|
|
1749
|
+
if (presentation?.icon) {
|
|
1495
1750
|
const caption = element('span', 'axiom-button-label');
|
|
1496
1751
|
caption.textContent = label;
|
|
1497
|
-
button.
|
|
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 (!
|
|
1843
|
+
if (!focusedInstance) {
|
|
1546
1844
|
return;
|
|
1547
1845
|
}
|
|
1548
|
-
const control = inputElements.get(
|
|
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.
|
|
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.
|
|
34
|
+
"@cynodia/axiom-core": "0.6.0-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|