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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,10 @@ The domain-independent runtime: the state store, expression evaluation, the muta
9
9
  engine, constraint checking, the semantic UI renderer and routing. It takes its whole
10
10
  environment through a `HostEnvironment`, so it runs in a browser or headlessly.
11
11
 
12
+ The renderer reads presentation that the compiler has already resolved and turns it into
13
+ semantic class names, landmark and heading elements, and formatted values. It writes no
14
+ styles and computes no lengths — what those classes mean is the theme's business.
15
+
12
16
  ## Installation
13
17
 
14
18
  ```bash
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,18 @@
1
+ import type { ResolvedPresentation, TextRole, 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. The three heading text roles are three levels: an application title, a title
13
+ * within it, and a section heading under that.
14
+ */
15
+ export declare function headingTag(textRole: TextRole | undefined): string | undefined;
16
+ /** The ARIA role a UX role implies, where one is warranted. */
17
+ export declare function ariaRoleFor(uxRole: UxRole | undefined): string | undefined;
18
+ //# sourceMappingURL=presentation-classes.d.ts.map
@@ -0,0 +1,147 @@
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
+ const classes = [`axiom-${prefix}layout-${layout.kind}`, `axiom-${prefix}gap-${layout.gap}`];
10
+ if (!prefix) {
11
+ classes.push(`axiom-align-${layout.align}`, `axiom-justify-${layout.justify}`);
12
+ classes.push(layout.wrap ? 'axiom-wrap' : 'axiom-nowrap');
13
+ if (layout.columns !== undefined) {
14
+ classes.push(typeof layout.columns === 'number'
15
+ ? `axiom-columns-${layout.columns}`
16
+ : `axiom-columns-adaptive-${layout.columns.minimum}`);
17
+ }
18
+ }
19
+ return classes;
20
+ }
21
+ function sizingClasses(prefix, sizing) {
22
+ const classes = [`axiom-${prefix}width-${sizing.width}`];
23
+ if (!prefix) {
24
+ classes.push(`axiom-height-${sizing.height}`);
25
+ if (sizing.minWidth) {
26
+ classes.push(`axiom-minwidth-${sizing.minWidth}`);
27
+ }
28
+ if (sizing.maxWidth) {
29
+ classes.push(`axiom-maxwidth-${sizing.maxWidth}`);
30
+ }
31
+ }
32
+ return classes;
33
+ }
34
+ function paddingClasses(prefix, padding) {
35
+ return [`axiom-${prefix}pad-x-${padding.horizontal}`, `axiom-${prefix}pad-y-${padding.vertical}`];
36
+ }
37
+ function responsiveClasses(device, override) {
38
+ const prefix = `${device}-`;
39
+ const classes = [];
40
+ if (override.hidden) {
41
+ classes.push(`axiom-${prefix}hidden`);
42
+ }
43
+ if (override.layout) {
44
+ classes.push(...layoutClasses(prefix, override.layout));
45
+ }
46
+ if (override.sizing) {
47
+ classes.push(...sizingClasses(prefix, override.sizing));
48
+ }
49
+ if (override.padding) {
50
+ classes.push(...paddingClasses(prefix, override.padding));
51
+ }
52
+ if (override.density) {
53
+ classes.push(`axiom-${prefix}density-${override.density}`);
54
+ }
55
+ return classes;
56
+ }
57
+ /** Every class a node's resolved presentation implies, in a stable order. */
58
+ export function presentationClassList(resolved) {
59
+ if (!resolved) {
60
+ return [];
61
+ }
62
+ const classes = [
63
+ `axiom-density-${resolved.density}`,
64
+ `axiom-emphasis-${resolved.emphasis}`,
65
+ `axiom-surface-${resolved.surface}`,
66
+ `axiom-text-${resolved.textRole}`,
67
+ `axiom-treatment-${resolved.treatment}`,
68
+ ];
69
+ if (resolved.role) {
70
+ classes.push(`axiom-role-${resolved.role}`);
71
+ }
72
+ if (resolved.uxRole) {
73
+ classes.push(`axiom-ux-${resolved.uxRole}`);
74
+ }
75
+ classes.push(...layoutClasses('', resolved.layout));
76
+ classes.push(...sizingClasses('', resolved.sizing));
77
+ classes.push(...paddingClasses('', resolved.padding));
78
+ for (const device of ['compact', 'regular', 'wide']) {
79
+ const override = resolved.responsive[device];
80
+ if (override) {
81
+ classes.push(...responsiveClasses(device, override));
82
+ }
83
+ }
84
+ // The escape hatch: a renderer-specific class, attached and otherwise not understood.
85
+ const override = resolved.rendererOverrides?.web;
86
+ if (override && typeof override.className === 'string' && override.className.trim()) {
87
+ classes.push('axiom-opaque', ...override.className.trim().split(/\s+/));
88
+ }
89
+ return classes;
90
+ }
91
+ export function presentationClasses(resolved, ...extra) {
92
+ return [...extra, ...presentationClassList(resolved)].filter(Boolean).join(' ');
93
+ }
94
+ /**
95
+ * Landmarks. A UX role that names a region of the page becomes the element that means
96
+ * that region, so assistive technology gets the structure the graph declared.
97
+ */
98
+ export function landmarkTag(uxRole) {
99
+ switch (uxRole) {
100
+ case 'header-region':
101
+ return 'header';
102
+ case 'footer-region':
103
+ return 'footer';
104
+ case 'navigation-group':
105
+ return 'nav';
106
+ case 'content-region':
107
+ return 'main';
108
+ case 'sidebar':
109
+ return 'aside';
110
+ case 'form-section':
111
+ return 'section';
112
+ default:
113
+ return undefined;
114
+ }
115
+ }
116
+ /**
117
+ * Headings are real headings, so the document has an outline rather than a set of large
118
+ * words. The three heading text roles are three levels: an application title, a title
119
+ * within it, and a section heading under that.
120
+ */
121
+ export function headingTag(textRole) {
122
+ switch (textRole) {
123
+ case 'display':
124
+ return 'h1';
125
+ case 'title':
126
+ return 'h2';
127
+ case 'heading':
128
+ return 'h3';
129
+ default:
130
+ return undefined;
131
+ }
132
+ }
133
+ /** The ARIA role a UX role implies, where one is warranted. */
134
+ export function ariaRoleFor(uxRole) {
135
+ switch (uxRole) {
136
+ case 'toolbar':
137
+ return 'toolbar';
138
+ case 'error-state':
139
+ return 'alert';
140
+ case 'warning-state':
141
+ case 'success-state':
142
+ case 'informational-state':
143
+ return 'status';
144
+ default:
145
+ return undefined;
146
+ }
147
+ }
package/dist/runtime.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { formatValue } from './format.js';
2
+ import { ariaRoleFor, headingTag, landmarkTag, presentationClasses, } from './presentation-classes.js';
1
3
  import { createMutationEngine } from './mutation/mutation-engine.js';
2
4
  import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
3
5
  import { createStateStore } from './mutation/store.js';
@@ -96,6 +98,14 @@ export function createAxiomRuntime(options) {
96
98
  let transactionCounter = 0;
97
99
  const mutationLog = [];
98
100
  const inputValidation = options.inputValidation ?? 'immediate';
101
+ const theme = ir.theme;
102
+ const locale = theme?.locale ?? 'en-US';
103
+ /** Messages for inputs whose last write was refused, so a control can say so. */
104
+ const inputErrors = new Map();
105
+ /** Presentation, already resolved by the compiler. The renderer decides nothing. */
106
+ function presentationOf(id) {
107
+ return ir.presentation?.[id];
108
+ }
99
109
  const statesById = new Map(ir.states.map((state) => [state.id, state]));
100
110
  const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
101
111
  const parameterTypes = new Map();
@@ -529,9 +539,9 @@ export function createAxiomRuntime(options) {
529
539
  }
530
540
  };
531
541
  for (const state of ir.states) {
532
- // Drafts are incomplete by definition, and derived states are views of data that
533
- // is already validated where it is stored.
534
- if (state.draft || state.derivation) {
542
+ // Drafts are incomplete by definition, ephemeral state is not a domain fact at all,
543
+ // and derived states are views of data already validated where it is stored.
544
+ if (state.draft || state.ephemeral || state.derivation) {
535
545
  continue;
536
546
  }
537
547
  visit(read(state.id), state.valueType);
@@ -897,8 +907,7 @@ export function createAxiomRuntime(options) {
897
907
  return { ok: false, diagnostics: [...collected] };
898
908
  }
899
909
  if (action.requiresConfirmation) {
900
- const message = action.confirmationMessage ?? `Confirm ${action.name ?? action.id}. This cannot be undone.`;
901
- if (!host.confirm(message)) {
910
+ if (!askForConfirmation(action)) {
902
911
  return { ok: false, diagnostics: [...collected] };
903
912
  }
904
913
  }
@@ -1007,18 +1016,49 @@ export function createAxiomRuntime(options) {
1007
1016
  }
1008
1017
  return created;
1009
1018
  }
1010
- function presentationClasses(node) {
1011
- const hints = node.presentation;
1012
- if (!hints) {
1013
- return '';
1014
- }
1015
- return [
1016
- hints.role ? `axiom-role-${hints.role}` : '',
1017
- hints.density ? `axiom-density-${hints.density}` : '',
1018
- hints.emphasis ? `axiom-emphasis-${hints.emphasis}` : '',
1019
- ]
1020
- .filter(Boolean)
1021
- .join(' ');
1019
+ /** The semantic classes a node's resolved presentation implies. No styles, no colours. */
1020
+ function nodeClasses(node, ...base) {
1021
+ return presentationClasses(presentationOf(node.id), ...base);
1022
+ }
1023
+ /** A semantic icon, drawn with the glyph the theme supplies for it. */
1024
+ function appendIcon(parent, resolved) {
1025
+ const name = resolved?.icon;
1026
+ if (!name) {
1027
+ return false;
1028
+ }
1029
+ const icon = element('span', 'axiom-icon');
1030
+ icon.setAttribute('aria-hidden', 'true');
1031
+ icon.setAttribute('data-icon', name);
1032
+ icon.textContent = theme?.icons?.[name] ?? '';
1033
+ parent.appendChild(icon);
1034
+ return true;
1035
+ }
1036
+ /**
1037
+ * Asks for confirmation. The graph describes what is asked; the host decides how to ask.
1038
+ * A host that can only manage a plain question still gets a sentence to show.
1039
+ */
1040
+ function askForConfirmation(action) {
1041
+ const declared = action.confirmation;
1042
+ const fallback = `Confirm ${action.name ?? action.id}. This cannot be undone.`;
1043
+ const composed = declared
1044
+ ? [declared.title, declared.description].filter(Boolean).join(' — ')
1045
+ : '';
1046
+ const message = action.confirmationMessage ?? (composed || fallback);
1047
+ if (!host.confirmRequest) {
1048
+ return host.confirm(message);
1049
+ }
1050
+ const request = {
1051
+ actionId: action.id,
1052
+ title: declared?.title ?? action.name ?? action.id,
1053
+ confirmLabel: declared?.confirmLabel ?? 'Confirm',
1054
+ cancelLabel: declared?.cancelLabel ?? 'Cancel',
1055
+ severity: declared?.severity ?? (action.destructive ? 'destructive' : 'informational'),
1056
+ message,
1057
+ ...(declared?.description ?? action.confirmationMessage
1058
+ ? { description: declared?.description ?? action.confirmationMessage }
1059
+ : {}),
1060
+ };
1061
+ return host.confirmRequest(request);
1022
1062
  }
1023
1063
  function renderChildren(ids, scope, parent) {
1024
1064
  for (const id of ids) {
@@ -1044,10 +1084,31 @@ export function createAxiomRuntime(options) {
1044
1084
  return null;
1045
1085
  }
1046
1086
  }
1087
+ /**
1088
+ * Which control edits this value. Semantic intent wins, then the older HTML-shaped
1089
+ * hint, then the type of the location the input is bound to.
1090
+ */
1047
1091
  function resolveInputTag(node) {
1048
1092
  const located = ir.locationTypes[node.id];
1049
1093
  const resolved = located ? unwrapType(located) : null;
1094
+ const enumValues = resolved?.kind === 'enum' ? resolved.values : [];
1050
1095
  const hint = node.inputHint;
1096
+ switch (presentationOf(node.id)?.control) {
1097
+ case 'multiline':
1098
+ return { tag: 'textarea', variant: 'multiline' };
1099
+ case 'select':
1100
+ return { tag: 'select', options: enumValues, variant: 'select' };
1101
+ case 'radio-group':
1102
+ return { tag: 'radio-group', options: enumValues, variant: 'radio-group' };
1103
+ case 'switch':
1104
+ return { tag: 'input', type: 'checkbox', variant: 'switch' };
1105
+ case 'checkbox':
1106
+ return { tag: 'input', type: 'checkbox', variant: 'checkbox' };
1107
+ case 'stepper':
1108
+ return { tag: 'input', type: 'number', variant: 'stepper' };
1109
+ default:
1110
+ break;
1111
+ }
1051
1112
  if (hint === 'multiline') {
1052
1113
  return { tag: 'textarea' };
1053
1114
  }
@@ -1055,7 +1116,7 @@ export function createAxiomRuntime(options) {
1055
1116
  return { tag: 'select' };
1056
1117
  }
1057
1118
  if (hint === 'select' || (!hint && resolved?.kind === 'enum')) {
1058
- return { tag: 'select', options: resolved?.kind === 'enum' ? resolved.values : [] };
1119
+ return { tag: 'select', options: enumValues };
1059
1120
  }
1060
1121
  if (hint === 'checkbox' || (!hint && resolved?.kind === 'primitive' && resolved.primitive === 'boolean')) {
1061
1122
  return { tag: 'input', type: 'checkbox' };
@@ -1131,28 +1192,55 @@ export function createAxiomRuntime(options) {
1131
1192
  if (node.visibleWhen && !toBoolean(evaluate(node.visibleWhen, scope))) {
1132
1193
  return null;
1133
1194
  }
1195
+ const presentation = presentationOf(node.id);
1134
1196
  switch (node.kind) {
1135
1197
  case 'view': {
1136
- const container = element('div', `axiom-view ${presentationClasses(node)}`.trim());
1198
+ const container = element('div', nodeClasses(node, 'axiom-view'));
1137
1199
  container.setAttribute('data-node', node.id);
1138
1200
  renderChildren(node.children, scope, container);
1139
1201
  return container;
1140
1202
  }
1141
1203
  case 'container': {
1142
- const container = element('div', `axiom-container axiom-layout-${node.layout ?? 'vertical'} ${presentationClasses(node)}`.trim());
1204
+ // A UX role that names a region of the page becomes the element that means it, so
1205
+ // the landmark structure an author declared is the one assistive technology sees.
1206
+ const container = element(landmarkTag(presentation?.uxRole) ?? 'div', nodeClasses(node, 'axiom-container'));
1143
1207
  container.setAttribute('data-node', node.id);
1208
+ const ariaRole = ariaRoleFor(presentation?.uxRole);
1209
+ if (ariaRole) {
1210
+ container.setAttribute('role', ariaRole);
1211
+ }
1212
+ if (presentation?.accessibleLabel) {
1213
+ container.setAttribute('aria-label', presentation.accessibleLabel);
1214
+ }
1215
+ appendIcon(container, presentation);
1144
1216
  renderChildren(node.children, scope, container);
1145
1217
  return container;
1146
1218
  }
1147
1219
  case 'text': {
1148
- const text = element('span', `axiom-text ${presentationClasses(node)}`.trim());
1220
+ const text = element(headingTag(presentation?.textRole) ?? 'span', nodeClasses(node, 'axiom-text'));
1149
1221
  text.setAttribute('data-node', node.id);
1150
- text.textContent =
1151
- typeof node.value === 'string' ? node.value : toText(evaluate(node.value, scope));
1222
+ // A status role announces itself whatever kind of node carries it.
1223
+ const textRole = ariaRoleFor(presentation?.uxRole);
1224
+ if (textRole) {
1225
+ text.setAttribute('role', textRole);
1226
+ }
1227
+ if (presentation?.accessibleLabel) {
1228
+ text.setAttribute('aria-label', presentation.accessibleLabel);
1229
+ }
1230
+ const raw = typeof node.value === 'string' ? node.value : evaluate(node.value, scope);
1231
+ const rendered = presentation?.format ? formatValue(raw, presentation.format, locale) : toText(raw);
1232
+ if (appendIcon(text, presentation)) {
1233
+ const value = element('span', 'axiom-text-value');
1234
+ value.textContent = rendered;
1235
+ text.appendChild(value);
1236
+ }
1237
+ else {
1238
+ text.textContent = rendered;
1239
+ }
1152
1240
  return text;
1153
1241
  }
1154
1242
  case 'repeat': {
1155
- const container = element('div', 'axiom-repeat');
1243
+ const container = element('div', nodeClasses(node, 'axiom-repeat'));
1156
1244
  container.setAttribute('data-node', node.id);
1157
1245
  const source = evaluate(node.source, scope);
1158
1246
  const items = Array.isArray(source) ? source : [];
@@ -1169,7 +1257,7 @@ export function createAxiomRuntime(options) {
1169
1257
  return container;
1170
1258
  }
1171
1259
  case 'field-display': {
1172
- const container = element('div', 'axiom-field');
1260
+ const container = element('div', nodeClasses(node, 'axiom-field'));
1173
1261
  container.setAttribute('data-node', node.id);
1174
1262
  const field = fieldOf(node.fieldId);
1175
1263
  if (node.label ?? field?.name) {
@@ -1177,21 +1265,27 @@ export function createAxiomRuntime(options) {
1177
1265
  label.textContent = node.label ?? field?.name ?? '';
1178
1266
  container.appendChild(label);
1179
1267
  }
1268
+ appendIcon(container, presentation);
1180
1269
  const value = element('span', 'axiom-field-value');
1181
1270
  const source = evaluate(node.source, scope);
1182
- value.textContent = isRecord(source) ? toText(source[node.fieldId]) : '';
1271
+ // The stored value is untouched; only what is shown is formatted.
1272
+ value.textContent = isRecord(source)
1273
+ ? formatValue(source[node.fieldId], presentation?.format, locale)
1274
+ : '';
1183
1275
  container.appendChild(value);
1184
1276
  return container;
1185
1277
  }
1186
1278
  case 'form': {
1187
- const form = element('form', 'axiom-form');
1279
+ const form = element('form', nodeClasses(node, 'axiom-form'));
1188
1280
  form.setAttribute('data-node', node.id);
1189
1281
  renderChildren(node.children, scope, form);
1190
1282
  if (node.submitActionId) {
1191
- const submit = element('button', 'axiom-submit');
1283
+ const actions = element('div', 'axiom-container axiom-ux-action-group axiom-layout-horizontal axiom-gap-small axiom-align-center axiom-justify-end axiom-wrap axiom-width-fill');
1284
+ const submit = element('button', 'axiom-submit axiom-button axiom-role-primary axiom-emphasis-strong axiom-ux-primary-action');
1192
1285
  submit.setAttribute('type', 'submit');
1193
1286
  submit.textContent = node.submitLabel ?? 'Submit';
1194
- form.appendChild(submit);
1287
+ actions.appendChild(submit);
1288
+ form.appendChild(actions);
1195
1289
  const actionId = node.submitActionId;
1196
1290
  form.addEventListener('submit', (event) => {
1197
1291
  event.preventDefault?.();
@@ -1201,25 +1295,86 @@ export function createAxiomRuntime(options) {
1201
1295
  return form;
1202
1296
  }
1203
1297
  case 'input': {
1204
- const wrapper = element('label', 'axiom-input');
1298
+ const descriptor = resolveInputTag(node);
1299
+ const grouped = descriptor.tag === 'radio-group';
1300
+ const controlId = `axiom-control-${node.id}`;
1301
+ const required = ir.locationRequired?.[node.id] === true;
1302
+ const wrapper = element(grouped ? 'div' : 'label', nodeClasses(node, 'axiom-input'));
1205
1303
  wrapper.setAttribute('data-node', node.id);
1206
- if (node.label) {
1207
- const label = element('span', 'axiom-input-label');
1208
- label.textContent = node.label;
1304
+ if (grouped) {
1305
+ wrapper.setAttribute('role', 'group');
1306
+ }
1307
+ else {
1308
+ // The label is the element, and it names the control by id as well, so the
1309
+ // association survives however the two are laid out.
1310
+ wrapper.setAttribute('for', controlId);
1311
+ }
1312
+ const labelText = node.label ?? presentation?.accessibleLabel;
1313
+ if (labelText) {
1314
+ const label = element('span', 'axiom-input-label axiom-text-label');
1315
+ label.textContent = labelText;
1316
+ if (required) {
1317
+ const marker = element('span', 'axiom-required-marker');
1318
+ marker.setAttribute('aria-hidden', 'true');
1319
+ marker.textContent = '*';
1320
+ label.appendChild(marker);
1321
+ }
1209
1322
  wrapper.appendChild(label);
1210
1323
  }
1211
- const descriptor = resolveInputTag(node);
1212
- const control = element(descriptor.tag, 'axiom-control');
1324
+ const current = readLocation(node.binding.location, scope);
1325
+ const describedBy = [];
1326
+ const control = element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control');
1213
1327
  control.setAttribute('data-node', node.id);
1214
- if (descriptor.type) {
1215
- control.setAttribute('type', descriptor.type);
1328
+ if (descriptor.variant) {
1329
+ control.setAttribute('data-control', descriptor.variant);
1216
1330
  }
1217
- if (node.placeholder) {
1218
- control.setAttribute('placeholder', node.placeholder);
1331
+ if (!grouped) {
1332
+ control.setAttribute('id', controlId);
1333
+ if (descriptor.type) {
1334
+ control.setAttribute('type', descriptor.type);
1335
+ }
1336
+ if (node.placeholder) {
1337
+ control.setAttribute('placeholder', node.placeholder);
1338
+ }
1339
+ if (required) {
1340
+ control.setAttribute('aria-required', 'true');
1341
+ }
1342
+ if (!labelText && presentation?.accessibleLabel) {
1343
+ control.setAttribute('aria-label', presentation.accessibleLabel);
1344
+ }
1219
1345
  }
1220
- const current = readLocation(node.binding.location, scope);
1221
- if (descriptor.type === 'checkbox') {
1346
+ if (grouped) {
1347
+ wrapper.setAttribute('data-control', descriptor.variant ?? 'radio-group');
1348
+ }
1349
+ if (descriptor.variant === 'switch') {
1350
+ control.setAttribute('role', 'switch');
1351
+ }
1352
+ const radios = [];
1353
+ if (grouped) {
1354
+ for (const choice of optionChoices(node, scope, descriptor.options ?? [])) {
1355
+ const option = element('label', 'axiom-radio-option');
1356
+ const radio = element('input', 'axiom-radio');
1357
+ radios.push(radio);
1358
+ radio.setAttribute('type', 'radio');
1359
+ radio.setAttribute('name', controlId);
1360
+ radio.setAttribute('value', choice.value);
1361
+ radio.value = choice.value;
1362
+ if (toText(current) === choice.value) {
1363
+ radio.checked = true;
1364
+ radio.setAttribute('checked', 'checked');
1365
+ }
1366
+ const caption = element('span');
1367
+ caption.textContent = choice.label;
1368
+ option.appendChild(radio);
1369
+ option.appendChild(caption);
1370
+ control.appendChild(option);
1371
+ }
1372
+ }
1373
+ else if (descriptor.type === 'checkbox') {
1222
1374
  control.checked = Boolean(current);
1375
+ if (Boolean(current)) {
1376
+ control.setAttribute('checked', 'checked');
1377
+ }
1223
1378
  }
1224
1379
  else if (descriptor.tag === 'select') {
1225
1380
  for (const choice of optionChoices(node, scope, descriptor.options ?? [])) {
@@ -1248,7 +1403,12 @@ export function createAxiomRuntime(options) {
1248
1403
  const next = coerceInputValue(node.id, source.value ?? '', source.checked);
1249
1404
  const rootStateId = ir.locationRoots[node.id];
1250
1405
  const rootState = rootStateId === undefined ? undefined : statesById.get(rootStateId);
1251
- const guarded = inputValidation === 'immediate' && rootState !== undefined && rootState.draft !== true;
1406
+ // A draft is incomplete while it is filled in, and ephemeral presentation state
1407
+ // is not a domain fact at all; neither is guarded per keystroke.
1408
+ const guarded = inputValidation === 'immediate' &&
1409
+ rootState !== undefined &&
1410
+ rootState.draft !== true &&
1411
+ rootState.ephemeral !== true;
1252
1412
  const before = guarded ? countViolations(hardViolations()) : null;
1253
1413
  const transaction = transactions.begin();
1254
1414
  const context = {
@@ -1260,6 +1420,7 @@ export function createAxiomRuntime(options) {
1260
1420
  mutate(() => mutations.set(node.binding.location, next, scope, context), context, failures);
1261
1421
  if (failures.length > 0) {
1262
1422
  settle(transaction, 'rolled-back');
1423
+ inputErrors.set(node.id, failures[0].message);
1263
1424
  failures.forEach(report);
1264
1425
  }
1265
1426
  else {
@@ -1271,6 +1432,7 @@ export function createAxiomRuntime(options) {
1271
1432
  ];
1272
1433
  if (rejected.length > 0) {
1273
1434
  settle(transaction, 'rolled-back');
1435
+ inputErrors.set(node.id, rejected[0].message);
1274
1436
  rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
1275
1437
  report({
1276
1438
  code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
@@ -1282,27 +1444,64 @@ export function createAxiomRuntime(options) {
1282
1444
  }
1283
1445
  else {
1284
1446
  settle(transaction, 'committed');
1447
+ inputErrors.delete(node.id);
1285
1448
  }
1286
1449
  }
1287
1450
  focusedNodeId = node.id;
1288
1451
  focusedCaret = typeof source.selectionStart === 'number' ? source.selectionStart : null;
1289
1452
  renderApplication();
1290
1453
  };
1291
- control.addEventListener('input', apply);
1292
- control.addEventListener('change', apply);
1293
- control.addEventListener('focus', () => {
1294
- focusedNodeId = node.id;
1295
- });
1454
+ // A radio group's listeners live on its radios; everything else is one control.
1455
+ for (const target of grouped ? radios : [control]) {
1456
+ target.addEventListener('input', apply);
1457
+ target.addEventListener('change', apply);
1458
+ target.addEventListener('focus', () => {
1459
+ focusedNodeId = node.id;
1460
+ });
1461
+ }
1296
1462
  inputElements.set(node.id, control);
1297
1463
  wrapper.appendChild(control);
1464
+ if (presentation?.description) {
1465
+ const help = element('span', 'axiom-input-description axiom-text-caption');
1466
+ help.setAttribute('id', `${controlId}-description`);
1467
+ help.textContent = presentation.description;
1468
+ describedBy.push(`${controlId}-description`);
1469
+ wrapper.appendChild(help);
1470
+ }
1471
+ const rejection = inputErrors.get(node.id);
1472
+ if (rejection) {
1473
+ // The refusal is related to the control it refused, not left floating.
1474
+ const error = element('span', 'axiom-input-description axiom-role-destructive axiom-text-caption');
1475
+ error.setAttribute('id', `${controlId}-error`);
1476
+ error.setAttribute('role', 'alert');
1477
+ error.textContent = rejection;
1478
+ describedBy.push(`${controlId}-error`);
1479
+ wrapper.appendChild(error);
1480
+ if (!grouped) {
1481
+ control.setAttribute('aria-invalid', 'true');
1482
+ }
1483
+ }
1484
+ if (describedBy.length > 0 && !grouped) {
1485
+ control.setAttribute('aria-describedby', describedBy.join(' '));
1486
+ }
1298
1487
  return wrapper;
1299
1488
  }
1300
1489
  case 'button': {
1301
- const button = element('button', `axiom-button ${node.destructive ? 'axiom-destructive' : ''} ${presentationClasses(node)}`.trim());
1490
+ const button = element('button', nodeClasses(node, 'axiom-button'));
1302
1491
  button.setAttribute('data-node', node.id);
1303
1492
  button.setAttribute('type', 'button');
1304
- button.textContent =
1305
- typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
1493
+ const label = typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
1494
+ if (appendIcon(button, presentation)) {
1495
+ const caption = element('span', 'axiom-button-label');
1496
+ caption.textContent = label;
1497
+ button.appendChild(caption);
1498
+ }
1499
+ else {
1500
+ button.textContent = label;
1501
+ }
1502
+ if (presentation?.accessibleLabel) {
1503
+ button.setAttribute('aria-label', presentation.accessibleLabel);
1504
+ }
1306
1505
  button.addEventListener('click', (event) => {
1307
1506
  event.preventDefault?.();
1308
1507
  const args = {};
@@ -1314,7 +1513,7 @@ export function createAxiomRuntime(options) {
1314
1513
  return button;
1315
1514
  }
1316
1515
  case 'conditional': {
1317
- const container = element('div', 'axiom-conditional');
1516
+ const container = element('div', nodeClasses(node, 'axiom-conditional'));
1318
1517
  container.setAttribute('data-node', node.id);
1319
1518
  const branch = toBoolean(evaluate(node.condition, scope)) ? node.whenTrue : node.whenFalse ?? [];
1320
1519
  renderChildren(branch, scope, container);
package/dist/source.js CHANGED
@@ -11,6 +11,8 @@ const RUNTIME_MODULES = [
11
11
  './mutation/transaction.js',
12
12
  './mutation/resolve-location.js',
13
13
  './mutation/mutation-engine.js',
14
+ './format.js',
15
+ './presentation-classes.js',
14
16
  './runtime.js',
15
17
  ];
16
18
  const BUNDLED_BASENAMES = RUNTIME_MODULES.map((module) => module.slice(module.lastIndexOf('/') + 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.4.1-alpha.1",
3
+ "version": "0.5.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.4.1-alpha.1"
34
+ "@cynodia/axiom-core": "0.5.0-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",