@cynodia/axiom-runtime 0.6.3-alpha.1 → 0.7.0-alpha.2

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/dist/dom.d.ts CHANGED
@@ -6,6 +6,16 @@ export interface DomEvent {
6
6
  type: string;
7
7
  preventDefault?(): void;
8
8
  target?: unknown;
9
+ /**
10
+ * The key, for a keyboard event.
11
+ *
12
+ * Named the way every host already names it, and read for exactly two purposes: dismissing
13
+ * a dialog on `Escape` and containing focus on `Tab`. It is not a general keyboard channel
14
+ * — an application cannot bind a key, because a key binding would be behaviour expressed
15
+ * outside the graph.
16
+ */
17
+ key?: string;
18
+ shiftKey?: boolean;
9
19
  }
10
20
  export type DomListener = (event: DomEvent) => void;
11
21
  export interface DomElement {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The two well-known field ids a `group` expression's results carry.
3
+ *
4
+ * Declared here rather than imported from `@cynodia/axiom-core` for the same reason `NodeId`
5
+ * is declared locally in `runtime-types.ts`: a value imported from core would be stripped
6
+ * out of the browser bundle and become `undefined` in a page. `packages/runtime/test/host.test.ts`
7
+ * fails if these drift from `GROUP_KEY_FIELD` and `GROUP_ITEMS_FIELD` in core.
8
+ */
9
+ export declare const GROUP_KEY_FIELD = "field_group_key";
10
+ export declare const GROUP_ITEMS_FIELD = "field_group_items";
11
+ //# sourceMappingURL=group-fields.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The two well-known field ids a `group` expression's results carry.
3
+ *
4
+ * Declared here rather than imported from `@cynodia/axiom-core` for the same reason `NodeId`
5
+ * is declared locally in `runtime-types.ts`: a value imported from core would be stripped
6
+ * out of the browser bundle and become `undefined` in a page. `packages/runtime/test/host.test.ts`
7
+ * fails if these drift from `GROUP_KEY_FIELD` and `GROUP_ITEMS_FIELD` in core.
8
+ */
9
+ export const GROUP_KEY_FIELD = 'field_group_key';
10
+ export const GROUP_ITEMS_FIELD = 'field_group_items';
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { BROWSER_RENDERER_CAPABILITIES } from './runtime-types.js';
1
2
  export * from './dom.js';
2
3
  export * from './mutation/values.js';
3
4
  export * from './mutation/store.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { BROWSER_RENDERER_CAPABILITIES } from './runtime-types.js';
1
2
  export * from './dom.js';
2
3
  export * from './mutation/values.js';
3
4
  export * from './mutation/store.js';
@@ -35,6 +35,10 @@ export class MemoryElement {
35
35
  }
36
36
  focus() {
37
37
  this.focused = true;
38
+ // A real host fires a focus event, and the renderer listens for one to track where focus
39
+ // is. A double that sets a flag without dispatching would let focus-dependent behaviour
40
+ // pass here and fail in a browser.
41
+ this.dispatch('focus');
38
42
  }
39
43
  dispatch(type, event = {}) {
40
44
  const payload = { type, target: this, preventDefault: () => undefined, ...event };
@@ -42,5 +42,12 @@ export declare function toText(value: unknown): string;
42
42
  */
43
43
  export declare function compareText(left: string, right: string): number;
44
44
  export declare function compareValues(left: unknown, right: unknown): number;
45
+ /**
46
+ * A stable serialization, so record comparison does not depend on key order.
47
+ *
48
+ * Also the identity of a group key: two keys are the same key when they serialize the same
49
+ * way, which is what lets a key be a record and not only a primitive.
50
+ */
51
+ export declare function canonicalKey(value: unknown): string;
45
52
  export declare function valuesEqual(left: unknown, right: unknown): boolean;
46
53
  //# sourceMappingURL=values.d.ts.map
@@ -104,7 +104,15 @@ export function compareValues(left, right) {
104
104
  }
105
105
  return compareText(toText(left), toText(right));
106
106
  }
107
- /** A stable serialization, so record comparison does not depend on key order. */
107
+ /**
108
+ * A stable serialization, so record comparison does not depend on key order.
109
+ *
110
+ * Also the identity of a group key: two keys are the same key when they serialize the same
111
+ * way, which is what lets a key be a record and not only a primitive.
112
+ */
113
+ export function canonicalKey(value) {
114
+ return canonical(value);
115
+ }
108
116
  function canonical(value) {
109
117
  if (value === null || typeof value !== 'object') {
110
118
  return JSON.stringify(value) ?? 'null';
@@ -54,4 +54,16 @@ export interface RemoteGateway {
54
54
  states: Record<NodeId, unknown>;
55
55
  }>;
56
56
  }
57
+ /**
58
+ * The UI node kinds the browser renderer draws.
59
+ *
60
+ * It lives beside the renderer rather than in core because the renderer is what has to be
61
+ * true to it: a kind listed here without a `case` in `renderNode` is a lie that validation
62
+ * would then repeat. `packages/runtime/test/host.test.ts` renders one of every kind on this
63
+ * list and fails if any reports `UNSUPPORTED_UI_NODE`.
64
+ */
65
+ export declare const BROWSER_RENDERER_CAPABILITIES: {
66
+ readonly target: "browser";
67
+ readonly supportedUiKinds: readonly ["view", "container", "text", "repeat", "field-display", "form", "input", "button", "conditional", "diagnostic", "dialog"];
68
+ };
57
69
  //# sourceMappingURL=runtime-types.d.ts.map
@@ -1 +1,24 @@
1
- export {};
1
+ /**
2
+ * The UI node kinds the browser renderer draws.
3
+ *
4
+ * It lives beside the renderer rather than in core because the renderer is what has to be
5
+ * true to it: a kind listed here without a `case` in `renderNode` is a lie that validation
6
+ * would then repeat. `packages/runtime/test/host.test.ts` renders one of every kind on this
7
+ * list and fails if any reports `UNSUPPORTED_UI_NODE`.
8
+ */
9
+ export const BROWSER_RENDERER_CAPABILITIES = {
10
+ target: 'browser',
11
+ supportedUiKinds: [
12
+ 'view',
13
+ 'container',
14
+ 'text',
15
+ 'repeat',
16
+ 'field-display',
17
+ 'form',
18
+ 'input',
19
+ 'button',
20
+ 'conditional',
21
+ 'diagnostic',
22
+ 'dialog',
23
+ ],
24
+ };
package/dist/runtime.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { GROUP_ITEMS_FIELD, GROUP_KEY_FIELD } from './group-fields.js';
1
2
  import { formatValue } from './format.js';
2
3
  import { ariaRoleFor, headingTag, landmarkTag, presentationClasses, } from './presentation-classes.js';
3
4
  import { createMutationEngine } from './mutation/mutation-engine.js';
4
5
  import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
5
6
  import { createStateStore } from './mutation/store.js';
6
7
  import { createTransactionManager } from './mutation/transaction.js';
7
- import { ExpressionEvaluationError, cloneValue, compareValues, deepFreeze, isEmptyValue, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
8
+ import { ExpressionEvaluationError, canonicalKey, cloneValue, compareValues, deepFreeze, isEmptyValue, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
8
9
  /**
9
10
  * The diagnostics a runtime can report. Agents match on the code rather than parsing the
10
11
  * message, so this vocabulary is part of the public contract.
@@ -133,6 +134,52 @@ export function createAxiomRuntime(options) {
133
134
  * convention about where inputs are bound.
134
135
  */
135
136
  let applyingAuthoritative = false;
137
+ /** Dialogs currently rendered open, so focus moves in once rather than on every render. */
138
+ const openDialogs = new Set();
139
+ /** The instance that held focus when each open dialog took it. */
140
+ const dialogReturnTargets = new Map();
141
+ /** An explicit override of where focus returns, by semantic node. */
142
+ const declaredReturnFocus = new Map();
143
+ /** The instance holding focus as the current render began. */
144
+ let openedFromInstance = null;
145
+ /**
146
+ * Where focus goes after this render, because a dialog just closed.
147
+ *
148
+ * A **render instance**, not a node id. `returnFocusId` names a semantic node, and a node
149
+ * inside a `repeat` is many rendered elements — so returning focus by node id sends a
150
+ * keyboard user to the last row rendered rather than the row they opened the dialog from.
151
+ * What is remembered instead is the instance that actually held focus when the dialog
152
+ * opened, which is both correct for repeats and correct when the trigger was not the
153
+ * declared `returnFocusId` at all.
154
+ */
155
+ let pendingFocusReturn = null;
156
+ /**
157
+ * A dialog that opened during this render, and the control focus belongs on.
158
+ *
159
+ * Focus cannot be moved while the tree is being built: an element that is not in the
160
+ * document yet is not focusable, so `focus()` on it does nothing in a browser. The
161
+ * in-memory host is more forgiving, which is exactly why this was wrong for a release and
162
+ * only a real browser caught it. Entry focus is therefore deferred to after the render,
163
+ * beside the focus restoration that already happens there.
164
+ */
165
+ let pendingDialogFocus = null;
166
+ /**
167
+ * Which render the elements on screen belong to.
168
+ *
169
+ * Rendering is a full replace, so removing a focused control makes the browser fire `blur`
170
+ * and — if its value changed — `change`, **on the element that was just detached**. Handling
171
+ * that as a new intent re-enters the render from inside itself and applies the same
172
+ * mutation twice. A control therefore ignores events that arrive after the render which
173
+ * created it. Nothing in the in-memory host fires those events, which is why only a real
174
+ * browser found this.
175
+ */
176
+ let renderGeneration = 0;
177
+ /** Every focusable control of the current render, by instance key. */
178
+ const focusableControls = new Map();
179
+ /** The instance that last held focus, so a dialog knows where to send it back. */
180
+ let lastFocusedControl = null;
181
+ /** Instance keys of the elements rendered for a semantic node, for `returnFocusId`. */
182
+ const instancesOfNode = new Map();
136
183
  let remoteRequests = 0;
137
184
  /** Generated once, and only when this runtime can actually talk to an authority. */
138
185
  const sessionId = remote ? `s${(runtimeSessions += 1)}-${host.uuid()}` : '';
@@ -210,6 +257,7 @@ export function createAxiomRuntime(options) {
210
257
  return safe === '' ? `i${index}` : safe;
211
258
  }
212
259
  const statesById = new Map(ir.states.map((state) => [state.id, state]));
260
+ const expressionDefsById = new Map(Object.entries(ir.expressionDefs ?? {}));
213
261
  const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
214
262
  const parameterTypes = new Map();
215
263
  for (const action of Object.values(ir.actions)) {
@@ -531,6 +579,45 @@ export function createAxiomRuntime(options) {
531
579
  const source = requireCollection(evaluate(expression.source, scope), 'flatten');
532
580
  return source.flatMap((member) => requireCollection(member, 'flatten'));
533
581
  }
582
+ case 'group': {
583
+ const source = requireCollection(evaluate(expression.source, scope), 'group');
584
+ // Groups in first-appearance order, members in source order, keys compared
585
+ // structurally. All three are contract, not implementation detail.
586
+ const groups = new Map();
587
+ for (const item of source) {
588
+ const key = evaluate(expression.by, childScope(scope, expression.scopeId, item));
589
+ const identity = canonicalKey(key);
590
+ const existing = groups.get(identity);
591
+ if (existing) {
592
+ existing.items.push(item);
593
+ }
594
+ else {
595
+ groups.set(identity, { key, items: [item] });
596
+ }
597
+ }
598
+ return [...groups.values()].map((entry) => ({
599
+ [GROUP_KEY_FIELD]: entry.key,
600
+ [GROUP_ITEMS_FIELD]: entry.items,
601
+ }));
602
+ }
603
+ case 'expression-ref': {
604
+ const definition = expressionDefsById.get(expression.expressionId);
605
+ if (!definition) {
606
+ throw new ExpressionEvaluationError(`Expression definition ${expression.expressionId} is not part of this application`, { expressionId: expression.expressionId });
607
+ }
608
+ // Arguments are evaluated here; the body is evaluated in a scope of its own, with
609
+ // no parent. That isolation is the definition's contract: it cannot see the
610
+ // caller's iteration scopes, so it means the same thing everywhere it is used.
611
+ const bound = new Map();
612
+ for (const parameter of definition.parameters ?? []) {
613
+ const argument = expression.arguments?.[String(parameter.id)];
614
+ if (argument === undefined) {
615
+ throw new ExpressionEvaluationError(`Expression ${definition.id} was used without supplying ${parameter.id}`, { expressionId: definition.id, parameterId: parameter.id });
616
+ }
617
+ bound.set(String(parameter.id), evaluate(argument, scope));
618
+ }
619
+ return evaluate(definition.expression, { values: bound });
620
+ }
534
621
  case 'conditional':
535
622
  return toBoolean(evaluate(expression.condition, scope))
536
623
  ? evaluate(expression.whenTrue, scope)
@@ -1309,7 +1396,37 @@ export function createAxiomRuntime(options) {
1309
1396
  }
1310
1397
  return created;
1311
1398
  }
1312
- /** The semantic classes a node's resolved presentation implies. No styles, no colours. */
1399
+ const dialogFocusStack = [];
1400
+ const openDialogFocus = new Map();
1401
+ /**
1402
+ * Registers a control the keyboard can reach.
1403
+ *
1404
+ * Every focusable control, not only the ones inside a dialog: a dialog has to know where
1405
+ * focus was before it opened, and that is somewhere else in the document by definition.
1406
+ * Every kind that can hold focus registers here — a dialog containing a text field must
1407
+ * trap it, and a trap that only knew about buttons would let focus walk straight out.
1408
+ */
1409
+ function registerFocusable(target, nodeIdentity, instanceKeyValue) {
1410
+ focusableControls.set(instanceKeyValue, target);
1411
+ const forNode = instancesOfNode.get(nodeIdentity) ?? [];
1412
+ forNode.push(instanceKeyValue);
1413
+ instancesOfNode.set(nodeIdentity, forNode);
1414
+ const current = dialogFocusStack[dialogFocusStack.length - 1];
1415
+ const index = current ? current.focusable.length : -1;
1416
+ if (current) {
1417
+ current.focusable.push(target);
1418
+ const dialogNode = ir.uiNodes[current.dialogId];
1419
+ if (dialogNode?.initialFocusId === nodeIdentity) {
1420
+ current.initial = target;
1421
+ }
1422
+ }
1423
+ target.addEventListener('focus', () => {
1424
+ lastFocusedControl = instanceKeyValue;
1425
+ if (current) {
1426
+ current.focusedIndex = index;
1427
+ }
1428
+ });
1429
+ }
1313
1430
  function nodeClasses(node, ...base) {
1314
1431
  return presentationClasses(presentationOf(node.id), ...base);
1315
1432
  }
@@ -1807,14 +1924,29 @@ export function createAxiomRuntime(options) {
1807
1924
  renderApplication();
1808
1925
  };
1809
1926
  // A radio group's listeners live on its radios; everything else is one control.
1927
+ const generation = renderGeneration;
1928
+ const applyIfCurrent = (event) => {
1929
+ if (generation !== renderGeneration) {
1930
+ // This element has already been replaced; the event is the browser tidying up
1931
+ // after it, not a person changing a value.
1932
+ return;
1933
+ }
1934
+ apply(event);
1935
+ };
1810
1936
  for (const target of grouped ? radios : [control]) {
1811
- target.addEventListener('input', apply);
1812
- target.addEventListener('change', apply);
1937
+ target.addEventListener('input', applyIfCurrent);
1938
+ target.addEventListener('change', applyIfCurrent);
1813
1939
  target.addEventListener('focus', () => {
1814
1940
  focusedInstance = instance;
1815
1941
  });
1816
1942
  }
1817
1943
  inputElements.set(instance, control);
1944
+ // A control is focusable whatever kind it is. A dialog containing a text field must
1945
+ // trap it, and a trap that knew only about buttons would let focus walk out of the
1946
+ // first input it met.
1947
+ for (const [offset, focusable] of (grouped ? radios : [control]).entries()) {
1948
+ registerFocusable(focusable, node.id, grouped ? `${instance}#${offset}` : instance);
1949
+ }
1818
1950
  wrapper.appendChild(control);
1819
1951
  if (presentation?.description) {
1820
1952
  const help = element('span', 'axiom-input-description axiom-text-caption');
@@ -1898,14 +2030,110 @@ export function createAxiomRuntime(options) {
1898
2030
  // The form invokes exactly this, so the button's arguments are not lost.
1899
2031
  button.setAttribute('type', 'submit');
1900
2032
  submitInvokers.set(instanceKey(submits.formId, path), invoke);
2033
+ registerFocusable(button, node.id, instance);
1901
2034
  return button;
1902
2035
  }
1903
2036
  button.addEventListener('click', (event) => {
1904
2037
  event.preventDefault?.();
1905
2038
  invoke();
1906
2039
  });
2040
+ registerFocusable(button, node.id, instance);
1907
2041
  return button;
1908
2042
  }
2043
+ case 'dialog': {
2044
+ // Visibility first: a closed dialog is absent, not hidden. Nothing inside it is
2045
+ // rendered, so nothing inside it is reachable by keyboard or by assistive technology.
2046
+ if (!toBoolean(evaluate(node.openWhen, scope))) {
2047
+ if (openDialogs.delete(node.id)) {
2048
+ // It was open and is now closed: focus goes back to whatever opened it, rather
2049
+ // than to the top of the document. A declared override wins, resolved after the
2050
+ // render, when its instances are known.
2051
+ const declared = declaredReturnFocus.get(node.id);
2052
+ pendingFocusReturn = declared
2053
+ ? `declared:${String(declared)}`
2054
+ : (dialogReturnTargets.get(node.id) ?? null);
2055
+ dialogReturnTargets.delete(node.id);
2056
+ }
2057
+ openDialogFocus.delete(node.id);
2058
+ return null;
2059
+ }
2060
+ const dialog = identify(element('div', nodeClasses(node, 'axiom-dialog')));
2061
+ dialog.setAttribute('role', 'dialog');
2062
+ if (node.modal !== false) {
2063
+ dialog.setAttribute('aria-modal', 'true');
2064
+ }
2065
+ // The accessible name is the title element, related by id — not a copied string, so
2066
+ // the name and the visible heading cannot drift apart.
2067
+ const titleId = `axiom-dialog-title-${instance}`;
2068
+ const title = element('h2', 'axiom-dialog-title axiom-text-heading');
2069
+ title.setAttribute('id', titleId);
2070
+ title.textContent =
2071
+ typeof node.title === 'string' ? node.title : toText(evaluate(node.title, scope));
2072
+ dialog.setAttribute('aria-labelledby', titleId);
2073
+ dialog.appendChild(title);
2074
+ if (node.description !== undefined) {
2075
+ const describedId = `axiom-dialog-description-${instance}`;
2076
+ const description = element('p', 'axiom-dialog-description axiom-text-body');
2077
+ description.setAttribute('id', describedId);
2078
+ description.textContent =
2079
+ typeof node.description === 'string'
2080
+ ? node.description
2081
+ : toText(evaluate(node.description, scope));
2082
+ dialog.setAttribute('aria-describedby', describedId);
2083
+ dialog.appendChild(description);
2084
+ }
2085
+ const focus = { dialogId: node.id, focusable: [], focusedIndex: -1 };
2086
+ dialogFocusStack.push(focus);
2087
+ renderChildren(node.children, scope, dialog, path);
2088
+ dialogFocusStack.pop();
2089
+ openDialogFocus.set(node.id, focus);
2090
+ // Escape dismisses by invoking the declared close action. What dismissal *means* is
2091
+ // that action's business: the runtime never infers that closing cancels anything.
2092
+ dialog.addEventListener('keydown', (event) => {
2093
+ if (event.key === 'Escape') {
2094
+ event.preventDefault?.();
2095
+ runAction(node.closeActionId, {});
2096
+ return;
2097
+ }
2098
+ if (event.key === 'Tab' && node.modal !== false) {
2099
+ // Containment: a modal that lets focus walk out of it is not modal. Only the
2100
+ // wrap is handled here; ordinary Tab movement is the host's.
2101
+ const { focusable, focusedIndex } = focus;
2102
+ if (focusable.length === 0) {
2103
+ return;
2104
+ }
2105
+ const atStart = focusedIndex <= 0;
2106
+ const atEnd = focusedIndex === focusable.length - 1;
2107
+ if (event.shiftKey ? atStart : atEnd) {
2108
+ event.preventDefault?.();
2109
+ const wrapTo = event.shiftKey ? focusable[focusable.length - 1] : focusable[0];
2110
+ wrapTo.focus?.();
2111
+ focus.focusedIndex = event.shiftKey ? focusable.length - 1 : 0;
2112
+ }
2113
+ }
2114
+ });
2115
+ // Focus moves in when it opens, and back out to whatever opened it when it closes.
2116
+ // Focus moves in the first time it is rendered open, and not on every re-render:
2117
+ // a full re-render must not steal focus back from wherever the person moved it.
2118
+ if (!openDialogs.has(node.id)) {
2119
+ openDialogs.add(node.id);
2120
+ // Where focus was, before this dialog took it. Captured by instance, so a trigger
2121
+ // inside a repeat sends focus back to the row it was actually pressed in.
2122
+ dialogReturnTargets.set(node.id, openedFromInstance ?? null);
2123
+ const initial = focus.initial ?? focus.focusable[0];
2124
+ // Applied once the tree is attached — see `pendingDialogFocus`.
2125
+ pendingDialogFocus = initial ?? null;
2126
+ focus.focusedIndex = initial ? focus.focusable.indexOf(initial) : -1;
2127
+ }
2128
+ // A declared `returnFocusId` is an override for the case where focus should not go
2129
+ // back where it came from. It can only name a node, so it resolves to that node's
2130
+ // sole rendered instance; naming one inside a repeat is ambiguous and validation
2131
+ // rejects it.
2132
+ if (node.returnFocusId) {
2133
+ declaredReturnFocus.set(node.id, node.returnFocusId);
2134
+ }
2135
+ return dialog;
2136
+ }
1909
2137
  case 'diagnostic': {
1910
2138
  const region = identify(element('div', nodeClasses(node, 'axiom-diagnostic')));
1911
2139
  region.setAttribute('id', `axiom-diagnostic-${instance}`);
@@ -1949,8 +2177,17 @@ export function createAxiomRuntime(options) {
1949
2177
  }
1950
2178
  }
1951
2179
  function renderApplication() {
2180
+ renderGeneration += 1;
1952
2181
  inputElements.clear();
1953
2182
  submitInvokers.clear();
2183
+ // Which instance held focus as this render began. A dialog opening during it captures
2184
+ // this, so closing returns focus to the control that was actually being used — the right
2185
+ // row of a repeat, not the last one rendered.
2186
+ openedFromInstance = lastFocusedControl;
2187
+ // The elements are about to be replaced; the instance keys they were registered under
2188
+ // are stable, so only the element references are stale.
2189
+ focusableControls.clear();
2190
+ instancesOfNode.clear();
1954
2191
  const scope = rootScope();
1955
2192
  if (!activeRoute) {
1956
2193
  const missing = element('div', 'axiom-no-route');
@@ -1962,7 +2199,47 @@ export function createAxiomRuntime(options) {
1962
2199
  rootElement.replaceChildren(...(view ? [view] : []));
1963
2200
  restoreFocus();
1964
2201
  }
2202
+ /**
2203
+ * Where focus goes when the control that opened a dialog no longer exists.
2204
+ *
2205
+ * A destructive confirmation usually removes the very row its trigger was in, so the exact
2206
+ * render instance is gone by the time the dialog closes. Dropping focus to the top of the
2207
+ * document would lose a keyboard user's place entirely; the nearest equivalent is another
2208
+ * instance of the same node — the next row's control. Absent that, nothing: an invented
2209
+ * target would be worse than none.
2210
+ */
2211
+ function survivingSibling(instanceKeyValue) {
2212
+ const nodeIdentity = instanceKeyValue.split('--')[0];
2213
+ const remaining = instancesOfNode.get(nodeIdentity) ?? [];
2214
+ for (const candidate of remaining) {
2215
+ const element = focusableControls.get(candidate);
2216
+ if (element) {
2217
+ return element;
2218
+ }
2219
+ }
2220
+ return undefined;
2221
+ }
1965
2222
  function restoreFocus() {
2223
+ // A dialog that just opened takes focus, now that its controls are in the document.
2224
+ if (pendingDialogFocus) {
2225
+ const entry = pendingDialogFocus;
2226
+ pendingDialogFocus = null;
2227
+ entry.focus?.();
2228
+ return;
2229
+ }
2230
+ // A dialog that just closed takes precedence: focus belongs on whatever opened it, not on
2231
+ // whatever happened to hold focus before the re-render.
2232
+ if (pendingFocusReturn) {
2233
+ const request = pendingFocusReturn;
2234
+ pendingFocusReturn = null;
2235
+ const target = request.startsWith('declared:')
2236
+ ? focusableControls.get((instancesOfNode.get(request.slice('declared:'.length)) ?? [])[0] ?? '')
2237
+ : (focusableControls.get(request) ?? survivingSibling(request));
2238
+ if (target) {
2239
+ target.focus?.();
2240
+ return;
2241
+ }
2242
+ }
1966
2243
  if (!focusedInstance) {
1967
2244
  return;
1968
2245
  }
@@ -2068,6 +2345,45 @@ export function createAxiomRuntime(options) {
2068
2345
  },
2069
2346
  };
2070
2347
  }
2348
+ /**
2349
+ * Local storage, where the browser will actually allow it.
2350
+ *
2351
+ * `localStorage` **exists and still throws**: an opaque origin — a sandboxed iframe, a
2352
+ * `srcdoc` document, a `data:` URL, a private window with storage blocked — denies access on
2353
+ * property read, not on use. Startup must not depend on that succeeding: a page that cannot
2354
+ * persist should render and work, keeping the state in memory instead of failing to boot.
2355
+ * Every call is guarded for the same reason: a write can fail on quota long after startup.
2356
+ */
2357
+ function browserStorage(globals) {
2358
+ let store;
2359
+ try {
2360
+ store = globals.localStorage;
2361
+ if (!store) {
2362
+ return undefined;
2363
+ }
2364
+ }
2365
+ catch {
2366
+ return undefined;
2367
+ }
2368
+ return {
2369
+ read: (key) => {
2370
+ try {
2371
+ return store.getItem(key);
2372
+ }
2373
+ catch {
2374
+ return null;
2375
+ }
2376
+ },
2377
+ write: (key, value) => {
2378
+ try {
2379
+ store.setItem(key, value);
2380
+ }
2381
+ catch {
2382
+ globals.console?.warn?.(`Axiom could not persist ${key}: storage is unavailable`);
2383
+ }
2384
+ },
2385
+ };
2386
+ }
2071
2387
  /** Builds a host bound to the browser globals. Used by generated pages. */
2072
2388
  export function createBrowserHost() {
2073
2389
  const globals = globalThis;
@@ -2081,12 +2397,7 @@ export function createBrowserHost() {
2081
2397
  uuid: () => typeof globals.crypto?.randomUUID === 'function'
2082
2398
  ? globals.crypto.randomUUID()
2083
2399
  : `id-${Date.now().toString(16)}-${Math.floor(Math.random() * 1e9).toString(16)}`,
2084
- storage: globals.localStorage
2085
- ? {
2086
- read: (key) => globals.localStorage.getItem(key),
2087
- write: (key, value) => globals.localStorage.setItem(key, value),
2088
- }
2089
- : undefined,
2400
+ storage: browserStorage(globals),
2090
2401
  report: (message) => globals.console?.warn?.(message),
2091
2402
  };
2092
2403
  }
package/dist/source.js CHANGED
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
6
6
  * bundle without a bundler.
7
7
  */
8
8
  const RUNTIME_MODULES = [
9
+ './group-fields.js',
9
10
  './mutation/values.js',
10
11
  './mutation/store.js',
11
12
  './mutation/transaction.js',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.6.3-alpha.1",
3
+ "version": "0.7.0-alpha.2",
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.6.3-alpha.1"
34
+ "@cynodia/axiom-core": "0.7.0-alpha.2"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",