@cynodia/axiom-runtime 0.4.1-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,6 +9,13 @@ 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 the compiler has already resolved and turns it into
13
+ semantic class names, landmark and heading elements, and formatted values. It writes no
14
+ styles and computes no lengths.
15
+
16
+ Main exports: `createAxiomRuntime`, `createBrowserHost`, `createMemoryHost`,
17
+ `RUNTIME_DIAGNOSTIC_CODES`, `formatValue`, `presentationClassList`.
18
+
12
19
  ## Installation
13
20
 
14
21
  ```bash
@@ -21,6 +28,13 @@ Most applications should install the facade package instead, which re-exports th
21
28
  npm install @cynodia/axiom@alpha
22
29
  ```
23
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
+
24
38
  ## License
25
39
 
26
40
  MIT
package/dist/dom.d.ts CHANGED
@@ -27,6 +27,21 @@ export interface StorageAdapter {
27
27
  read(key: string): string | null;
28
28
  write(key: string, value: string): void;
29
29
  }
30
+ /**
31
+ * A confirmation, described rather than drawn. A host may present it however its platform
32
+ * presents such things; nothing here is browser-specific, and the graph never constructs
33
+ * dialog markup.
34
+ */
35
+ export interface ConfirmationRequest {
36
+ actionId: string;
37
+ title: string;
38
+ description?: string;
39
+ confirmLabel: string;
40
+ cancelLabel: string;
41
+ severity: 'informational' | 'warning' | 'destructive';
42
+ /** A single line for hosts that can only ask a plain question. */
43
+ message: string;
44
+ }
30
45
  /** Everything the runtime needs from its environment, so nothing is read from globals. */
31
46
  export interface HostEnvironment {
32
47
  document: DomDocument;
@@ -34,6 +49,8 @@ export interface HostEnvironment {
34
49
  pushPath(path: string): void;
35
50
  onPathChange(listener: () => void): void;
36
51
  confirm(message: string): boolean;
52
+ /** Preferred over `confirm` when the host can present a structured confirmation. */
53
+ confirmRequest?(request: ConfirmationRequest): boolean;
37
54
  now(): string;
38
55
  uuid(): string;
39
56
  storage?: StorageAdapter;
@@ -0,0 +1,8 @@
1
+ import type { ValueFormat } from '@cynodia/axiom-core';
2
+ /**
3
+ * Renders a value for display. A value the format cannot describe — a date field holding
4
+ * something that is not a date — falls back to its plain text rather than inventing a
5
+ * plausible-looking result.
6
+ */
7
+ export declare function formatValue(value: unknown, format: ValueFormat | undefined, locale?: string): string;
8
+ //# sourceMappingURL=format.d.ts.map
package/dist/format.js ADDED
@@ -0,0 +1,122 @@
1
+ import { isPresent, toText } from './mutation/values.js';
2
+ /**
3
+ * Semantic value formatting. Formatting is presentation: the stored value never changes,
4
+ * and the graph describes what it wants with a structured `ValueFormat` rather than a
5
+ * function, so the description survives serialization and can be reasoned about.
6
+ */
7
+ const DATE_STYLES = {
8
+ short: 'short',
9
+ medium: 'medium',
10
+ long: 'long',
11
+ };
12
+ function numberFormat(locale, options) {
13
+ const intl = globalThis.Intl;
14
+ if (!intl?.NumberFormat) {
15
+ return (value) => String(value);
16
+ }
17
+ const formatter = new intl.NumberFormat(locale, options);
18
+ return (value) => formatter.format(value);
19
+ }
20
+ function asNumber(value) {
21
+ if (typeof value === 'number') {
22
+ return Number.isFinite(value) ? value : null;
23
+ }
24
+ if (typeof value === 'string' && value.trim() !== '') {
25
+ const parsed = Number(value);
26
+ return Number.isFinite(parsed) ? parsed : null;
27
+ }
28
+ return null;
29
+ }
30
+ function asDate(value) {
31
+ if (value instanceof Date) {
32
+ return Number.isNaN(value.getTime()) ? null : value;
33
+ }
34
+ if (typeof value === 'string' || typeof value === 'number') {
35
+ const parsed = new Date(value);
36
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
37
+ }
38
+ return null;
39
+ }
40
+ function formatDate(value, locale, style, withTime) {
41
+ const date = asDate(value);
42
+ if (!date) {
43
+ return null;
44
+ }
45
+ const intl = globalThis.Intl;
46
+ if (!intl?.DateTimeFormat) {
47
+ return date.toISOString();
48
+ }
49
+ const dateStyle = DATE_STYLES[style ?? 'medium'] ?? 'medium';
50
+ const options = withTime
51
+ ? { dateStyle, timeStyle: dateStyle === 'long' ? 'medium' : 'short' }
52
+ : { dateStyle };
53
+ return new intl.DateTimeFormat(locale, options).format(date);
54
+ }
55
+ /**
56
+ * Renders a value for display. A value the format cannot describe — a date field holding
57
+ * something that is not a date — falls back to its plain text rather than inventing a
58
+ * plausible-looking result.
59
+ */
60
+ export function formatValue(value, format, locale = 'en-US') {
61
+ if (!format) {
62
+ return toText(value);
63
+ }
64
+ if (format.kind !== 'boolean' && !isPresent(value)) {
65
+ return '';
66
+ }
67
+ switch (format.kind) {
68
+ case 'text':
69
+ return toText(value);
70
+ case 'number': {
71
+ const numeric = asNumber(value);
72
+ if (numeric === null) {
73
+ return toText(value);
74
+ }
75
+ return numberFormat(locale, {
76
+ useGrouping: format.grouping !== false,
77
+ ...(format.decimals === undefined
78
+ ? {}
79
+ : { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
80
+ })(numeric);
81
+ }
82
+ case 'currency': {
83
+ const numeric = asNumber(value);
84
+ if (numeric === null) {
85
+ return toText(value);
86
+ }
87
+ return numberFormat(locale, {
88
+ style: 'currency',
89
+ currency: format.currency,
90
+ ...(format.decimals === undefined
91
+ ? {}
92
+ : { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
93
+ })(numeric);
94
+ }
95
+ case 'percentage': {
96
+ const numeric = asNumber(value);
97
+ if (numeric === null) {
98
+ return toText(value);
99
+ }
100
+ // A percentage may be stored as a fraction or as a whole number of percent.
101
+ const fraction = format.scale === 'percent' ? numeric / 100 : numeric;
102
+ return numberFormat(locale, {
103
+ style: 'percent',
104
+ ...(format.decimals === undefined
105
+ ? {}
106
+ : { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
107
+ })(fraction);
108
+ }
109
+ case 'boolean': {
110
+ if (!isPresent(value)) {
111
+ return '';
112
+ }
113
+ return value ? format.trueLabel ?? 'Yes' : format.falseLabel ?? 'No';
114
+ }
115
+ case 'date':
116
+ return formatDate(value, locale, format.style, false) ?? toText(value);
117
+ case 'datetime':
118
+ return formatDate(value, locale, format.style, true) ?? toText(value);
119
+ default:
120
+ return toText(value);
121
+ }
122
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export * from './mutation/store.js';
4
4
  export * from './mutation/transaction.js';
5
5
  export * from './mutation/resolve-location.js';
6
6
  export * from './mutation/mutation-engine.js';
7
+ export * from './format.js';
8
+ export * from './presentation-classes.js';
7
9
  export * from './runtime.js';
8
10
  export * from './memory-host.js';
9
11
  export * from './source.js';
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ export * from './mutation/store.js';
4
4
  export * from './mutation/transaction.js';
5
5
  export * from './mutation/resolve-location.js';
6
6
  export * from './mutation/mutation-engine.js';
7
+ export * from './format.js';
8
+ export * from './presentation-classes.js';
7
9
  export * from './runtime.js';
8
10
  export * from './memory-host.js';
9
11
  export * from './source.js';
@@ -1,4 +1,4 @@
1
- import type { DomDocument, DomElement, DomEvent, DomListener, HostEnvironment, StorageAdapter } from './dom.js';
1
+ import type { ConfirmationRequest, DomDocument, DomElement, DomEvent, DomListener, HostEnvironment, StorageAdapter } from './dom.js';
2
2
  /**
3
3
  * An in-memory DOM and host. The runtime never touches browser globals directly, so the
4
4
  * same renderer that drives a real page can be driven headlessly in tests.
@@ -36,6 +36,8 @@ export interface MemoryHost extends HostEnvironment {
36
36
  path: string;
37
37
  reports: string[];
38
38
  confirmations: string[];
39
+ /** The structured confirmations asked for, so a test can assert what was presented. */
40
+ confirmationRequests: ConfirmationRequest[];
39
41
  storage?: StorageAdapter;
40
42
  }
41
43
  export declare function createMemoryHost(options?: MemoryHostOptions): MemoryHost;
@@ -61,6 +61,7 @@ export function createMemoryHost(options = {}) {
61
61
  path: options.path ?? '/',
62
62
  reports: [],
63
63
  confirmations: [],
64
+ confirmationRequests: [],
64
65
  document: new MemoryDocument(),
65
66
  getPath: () => host.path,
66
67
  pushPath: (next) => {
@@ -73,6 +74,10 @@ export function createMemoryHost(options = {}) {
73
74
  host.confirmations.push(message);
74
75
  return typeof confirmResult === 'function' ? confirmResult() : confirmResult;
75
76
  },
77
+ confirmRequest: (request) => {
78
+ host.confirmationRequests.push(request);
79
+ return host.confirm(request.message);
80
+ },
76
81
  now: () => {
77
82
  counter += 1;
78
83
  return `2026-01-01T00:00:${String(counter).padStart(2, '0')}.000Z`;
@@ -0,0 +1,21 @@
1
+ import type { ResolvedPresentation, UxRole } from '@cynodia/axiom-core';
2
+ /** Every class a node's resolved presentation implies, in a stable order. */
3
+ export declare function presentationClassList(resolved: ResolvedPresentation | undefined): string[];
4
+ export declare function presentationClasses(resolved: ResolvedPresentation | undefined, ...extra: string[]): string;
5
+ /**
6
+ * Landmarks. A UX role that names a region of the page becomes the element that means
7
+ * that region, so assistive technology gets the structure the graph declared.
8
+ */
9
+ export declare function landmarkTag(uxRole: UxRole | undefined): string | undefined;
10
+ /**
11
+ * Headings are real headings, so the document has an outline rather than a set of large
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.
17
+ */
18
+ export declare function headingTag(resolved: ResolvedPresentation | undefined): string | undefined;
19
+ /** The ARIA role a UX role implies, where one is warranted. */
20
+ export declare function ariaRoleFor(uxRole: UxRole | undefined): string | undefined;
21
+ //# sourceMappingURL=presentation-classes.d.ts.map
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The web renderer's translation of resolved presentation into class names.
3
+ *
4
+ * The renderer emits semantic classes and nothing else — no inline styles, no computed
5
+ * lengths, no colours. What those classes mean is decided by the generated stylesheet, so
6
+ * the same resolved presentation can drive an entirely different renderer.
7
+ */
8
+ function layoutClasses(prefix, layout) {
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
+ }
15
+ if (!prefix) {
16
+ classes.push(`axiom-align-${layout.align}`, `axiom-justify-${layout.justify}`);
17
+ classes.push(layout.wrap ? 'axiom-wrap' : 'axiom-nowrap');
18
+ if (layout.columns !== undefined) {
19
+ classes.push(typeof layout.columns === 'number'
20
+ ? `axiom-columns-${layout.columns}`
21
+ : `axiom-columns-adaptive-${layout.columns.minimum}`);
22
+ }
23
+ }
24
+ return classes;
25
+ }
26
+ function sizingClasses(prefix, sizing) {
27
+ const classes = [`axiom-${prefix}width-${sizing.width}`];
28
+ if (!prefix) {
29
+ classes.push(`axiom-height-${sizing.height}`);
30
+ if (sizing.minWidth) {
31
+ classes.push(`axiom-minwidth-${sizing.minWidth}`);
32
+ }
33
+ if (sizing.maxWidth) {
34
+ classes.push(`axiom-maxwidth-${sizing.maxWidth}`);
35
+ }
36
+ }
37
+ return classes;
38
+ }
39
+ function paddingClasses(prefix, padding) {
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;
48
+ }
49
+ function responsiveClasses(device, override) {
50
+ const prefix = `${device}-`;
51
+ const classes = [];
52
+ if (override.hidden) {
53
+ classes.push(`axiom-${prefix}hidden`);
54
+ }
55
+ if (override.layout) {
56
+ classes.push(...layoutClasses(prefix, override.layout));
57
+ }
58
+ if (override.sizing) {
59
+ classes.push(...sizingClasses(prefix, override.sizing));
60
+ }
61
+ if (override.padding) {
62
+ classes.push(...paddingClasses(prefix, override.padding));
63
+ }
64
+ if (override.density) {
65
+ classes.push(`axiom-${prefix}density-${override.density}`);
66
+ }
67
+ return classes;
68
+ }
69
+ /** Every class a node's resolved presentation implies, in a stable order. */
70
+ export function presentationClassList(resolved) {
71
+ if (!resolved) {
72
+ return [];
73
+ }
74
+ const classes = [
75
+ `axiom-density-${resolved.density}`,
76
+ `axiom-emphasis-${resolved.emphasis}`,
77
+ `axiom-surface-${resolved.surface}`,
78
+ `axiom-text-${resolved.textRole}`,
79
+ `axiom-treatment-${resolved.treatment}`,
80
+ ];
81
+ if (resolved.role) {
82
+ classes.push(`axiom-role-${resolved.role}`);
83
+ }
84
+ if (resolved.uxRole) {
85
+ classes.push(`axiom-ux-${resolved.uxRole}`);
86
+ }
87
+ classes.push(...layoutClasses('', resolved.layout));
88
+ classes.push(...sizingClasses('', resolved.sizing));
89
+ classes.push(...paddingClasses('', resolved.padding));
90
+ for (const device of ['compact', 'regular', 'wide']) {
91
+ const override = resolved.responsive[device];
92
+ if (override) {
93
+ classes.push(...responsiveClasses(device, override));
94
+ }
95
+ }
96
+ // The escape hatch: a renderer-specific class, attached and otherwise not understood.
97
+ const override = resolved.rendererOverrides?.web;
98
+ if (override && typeof override.className === 'string' && override.className.trim()) {
99
+ classes.push('axiom-opaque', ...override.className.trim().split(/\s+/));
100
+ }
101
+ return classes;
102
+ }
103
+ export function presentationClasses(resolved, ...extra) {
104
+ return [...extra, ...presentationClassList(resolved)].filter(Boolean).join(' ');
105
+ }
106
+ /**
107
+ * Landmarks. A UX role that names a region of the page becomes the element that means
108
+ * that region, so assistive technology gets the structure the graph declared.
109
+ */
110
+ export function landmarkTag(uxRole) {
111
+ switch (uxRole) {
112
+ case 'header-region':
113
+ return 'header';
114
+ case 'footer-region':
115
+ return 'footer';
116
+ case 'navigation-group':
117
+ return 'nav';
118
+ case 'content-region':
119
+ return 'main';
120
+ case 'sidebar':
121
+ return 'aside';
122
+ case 'form-section':
123
+ return 'section';
124
+ default:
125
+ return undefined;
126
+ }
127
+ }
128
+ /**
129
+ * Headings are real headings, so the document has an outline rather than a set of large
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.
135
+ */
136
+ export function headingTag(resolved) {
137
+ const level = resolved?.headingLevel;
138
+ return typeof level === 'number' ? `h${level}` : undefined;
139
+ }
140
+ /** The ARIA role a UX role implies, where one is warranted. */
141
+ export function ariaRoleFor(uxRole) {
142
+ switch (uxRole) {
143
+ case 'toolbar':
144
+ return 'toolbar';
145
+ case 'error-state':
146
+ return 'alert';
147
+ case 'warning-state':
148
+ case 'success-state':
149
+ case 'informational-state':
150
+ return 'status';
151
+ default:
152
+ return undefined;
153
+ }
154
+ }
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;