@hypen-space/web 0.5.2 → 0.5.3

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.
Files changed (104) hide show
  1. package/dist/canvas/accessibility.d.ts +118 -11
  2. package/dist/canvas/accessibility.js +470 -108
  3. package/dist/canvas/accessibility.js.map +1 -1
  4. package/dist/canvas/dispatch.d.ts +24 -0
  5. package/dist/canvas/dispatch.js +62 -0
  6. package/dist/canvas/dispatch.js.map +1 -0
  7. package/dist/canvas/editing.d.ts +178 -0
  8. package/dist/canvas/editing.js +590 -0
  9. package/dist/canvas/editing.js.map +1 -0
  10. package/dist/canvas/events.d.ts +28 -23
  11. package/dist/canvas/events.js +76 -128
  12. package/dist/canvas/events.js.map +1 -1
  13. package/dist/canvas/focus.d.ts +78 -0
  14. package/dist/canvas/focus.js +182 -0
  15. package/dist/canvas/focus.js.map +1 -0
  16. package/dist/canvas/index.d.ts +3 -1
  17. package/dist/canvas/index.js +3 -1
  18. package/dist/canvas/index.js.map +1 -1
  19. package/dist/canvas/paint.d.ts +16 -3
  20. package/dist/canvas/paint.js +283 -41
  21. package/dist/canvas/paint.js.map +1 -1
  22. package/dist/canvas/renderer.d.ts +20 -1
  23. package/dist/canvas/renderer.js +181 -50
  24. package/dist/canvas/renderer.js.map +1 -1
  25. package/dist/canvas/selection.js +31 -177
  26. package/dist/canvas/selection.js.map +1 -1
  27. package/dist/canvas/text-geometry.d.ts +72 -0
  28. package/dist/canvas/text-geometry.js +244 -0
  29. package/dist/canvas/text-geometry.js.map +1 -0
  30. package/dist/canvas/text.js +42 -9
  31. package/dist/canvas/text.js.map +1 -1
  32. package/dist/canvas/types.d.ts +7 -1
  33. package/dist/canvas/variants.d.ts +4 -1
  34. package/dist/canvas/variants.js +22 -3
  35. package/dist/canvas/variants.js.map +1 -1
  36. package/dist/dom/a11y-styles.d.ts +20 -0
  37. package/dist/dom/a11y-styles.js +61 -0
  38. package/dist/dom/a11y-styles.js.map +1 -0
  39. package/dist/dom/applicators/aria.d.ts +19 -0
  40. package/dist/dom/applicators/aria.js +36 -0
  41. package/dist/dom/applicators/aria.js.map +1 -0
  42. package/dist/dom/applicators/events.d.ts +7 -0
  43. package/dist/dom/applicators/events.js +35 -8
  44. package/dist/dom/applicators/events.js.map +1 -1
  45. package/dist/dom/applicators/index.js +4 -0
  46. package/dist/dom/applicators/index.js.map +1 -1
  47. package/dist/dom/components/hypenapp.d.ts +25 -1
  48. package/dist/dom/components/hypenapp.js +213 -245
  49. package/dist/dom/components/hypenapp.js.map +1 -1
  50. package/dist/dom/components/index.js +7 -0
  51. package/dist/dom/components/index.js.map +1 -1
  52. package/dist/dom/components/spinner.js +24 -10
  53. package/dist/dom/components/spinner.js.map +1 -1
  54. package/dist/dom/components/tabs.d.ts +27 -0
  55. package/dist/dom/components/tabs.js +69 -0
  56. package/dist/dom/components/tabs.js.map +1 -0
  57. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  58. package/dist/dom/components/visuallyhidden.js +26 -0
  59. package/dist/dom/components/visuallyhidden.js.map +1 -0
  60. package/dist/dom/operability.d.ts +100 -0
  61. package/dist/dom/operability.js +298 -0
  62. package/dist/dom/operability.js.map +1 -0
  63. package/dist/dom/renderer.d.ts +98 -5
  64. package/dist/dom/renderer.js +398 -63
  65. package/dist/dom/renderer.js.map +1 -1
  66. package/dist/dom/route-focus.d.ts +42 -0
  67. package/dist/dom/route-focus.js +88 -0
  68. package/dist/dom/route-focus.js.map +1 -0
  69. package/dist/dom/semantics.d.ts +35 -0
  70. package/dist/dom/semantics.js +184 -0
  71. package/dist/dom/semantics.js.map +1 -0
  72. package/package.json +2 -2
  73. package/src/canvas/QUICKSTART.md +4 -4
  74. package/src/canvas/README.md +55 -29
  75. package/src/canvas/accessibility.ts +507 -115
  76. package/src/canvas/dispatch.ts +78 -0
  77. package/src/canvas/editing.ts +697 -0
  78. package/src/canvas/events.ts +89 -142
  79. package/src/canvas/focus.ts +216 -0
  80. package/src/canvas/index.ts +8 -1
  81. package/src/canvas/paint.ts +339 -42
  82. package/src/canvas/renderer.ts +218 -70
  83. package/src/canvas/selection.ts +52 -210
  84. package/src/canvas/text-geometry.ts +311 -0
  85. package/src/canvas/text.ts +43 -9
  86. package/src/canvas/types.ts +14 -1
  87. package/src/canvas/variants.ts +24 -5
  88. package/src/dom/a11y-styles.ts +65 -0
  89. package/src/dom/applicators/aria.ts +41 -0
  90. package/src/dom/applicators/events.ts +38 -7
  91. package/src/dom/applicators/index.ts +5 -0
  92. package/src/dom/components/hypenapp.ts +244 -272
  93. package/src/dom/components/index.ts +7 -0
  94. package/src/dom/components/spinner.ts +31 -13
  95. package/src/dom/components/tabs.ts +76 -0
  96. package/src/dom/components/visuallyhidden.ts +30 -0
  97. package/src/dom/operability.ts +312 -0
  98. package/src/dom/renderer.ts +455 -63
  99. package/src/dom/route-focus.ts +98 -0
  100. package/src/dom/semantics.ts +199 -0
  101. package/dist/canvas/input.d.ts +0 -76
  102. package/dist/canvas/input.js +0 -207
  103. package/dist/canvas/input.js.map +0 -1
  104. package/src/canvas/input.ts +0 -251
@@ -8,10 +8,42 @@ import type { Patch } from "@hypen-space/core/types";
8
8
  import type { HypenModuleInstance } from "@hypen-space/core/app";
9
9
  import type { HypenRouter } from "@hypen-space/core/router";
10
10
  import type { HypenGlobalContext } from "@hypen-space/core/context";
11
- import { frameworkLoggers } from "@hypen-space/core/logger";
11
+ import { frameworkLoggers, isDebugMode } from "@hypen-space/core/logger";
12
12
 
13
13
  const log = frameworkLoggers.renderer;
14
14
 
15
+ /** A compiled piece of a text template: literal text, or an `@{...}` binding. */
16
+ type TemplateSegment = string | { match: string; keys: string[] };
17
+
18
+ interface TextBinding {
19
+ element: HTMLElement;
20
+ template: string;
21
+ segments: TemplateSegment[];
22
+ }
23
+
24
+ /**
25
+ * Split a text template into literal and `@{state.path}` binding segments,
26
+ * so per-update interpolation walks pre-split paths instead of re-running
27
+ * the regex over the whole template.
28
+ */
29
+ function compileTextTemplate(template: string): TemplateSegment[] {
30
+ const bindingPattern = /@\{([^}]+)\}/g;
31
+ const segments: TemplateSegment[] = [];
32
+ let last = 0;
33
+ let match: RegExpExecArray | null;
34
+ while ((match = bindingPattern.exec(template)) !== null) {
35
+ if (match.index > last) {
36
+ segments.push(template.slice(last, match.index));
37
+ }
38
+ segments.push({ match: match[0], keys: match[1]!.split(".") });
39
+ last = match.index + match[0].length;
40
+ }
41
+ if (last < template.length) {
42
+ segments.push(template.slice(last));
43
+ }
44
+ return segments;
45
+ }
46
+
15
47
  /** Element types that treat the "action" prop as an onClick handler */
16
48
  const ACTIONABLE_TYPES = new Set(["button", "link", "card"]);
17
49
 
@@ -31,20 +63,48 @@ const COMPONENT_HTML_ATTRS: Record<string, Set<string>> = {
31
63
  checkbox: new Set(["checked", "disabled", "name"]),
32
64
  radio: new Set(["checked", "disabled", "name", "value"]),
33
65
  link: new Set(["href", "target", "rel"]),
66
+ // Route URL changes to the handler so it reconnects the embedded app
67
+ // instead of the generic text branch overwriting the subtree.
68
+ hypenapp: new Set(["0", "url"]),
34
69
  };
35
70
 
36
71
  import { ComponentRegistry } from "./components/index.js";
37
72
  import { ApplicatorRegistry } from "./applicators/index.js";
73
+ import { applySemantics } from "./semantics.js";
74
+ import {
75
+ makeKeyboardActivatable,
76
+ makeFocusTrap,
77
+ makeRovingTablist,
78
+ makeRovingListbox,
79
+ focusDialogOnOpen,
80
+ restoreDialogFocus,
81
+ installDialogEscape,
82
+ } from "./operability.js";
83
+ import { findRouteFocusTarget, focusRouteTarget } from "./route-focus.js";
84
+ import type { Semantics } from "@hypen-space/core/types";
38
85
  import { canvasHandler, canvasApplicators } from "./canvas/index.js";
39
86
  import { CanvasRenderer } from "../canvas/renderer.js";
40
87
  import { RerenderTracker, type DebugConfig, defaultDebugConfig } from "./debug.js";
41
88
  import { setEngine, disposeHypenElement } from "./element-data.js";
89
+ import { ensureA11yStyles } from "./a11y-styles.js";
42
90
 
43
91
  // Interface for the engine that renderer needs
44
92
  interface IEngine {
45
93
  dispatchAction(name: string, payload?: any): void;
46
94
  }
47
95
 
96
+ export interface DOMRendererOptions {
97
+ /**
98
+ * Route-change focus contract (route-focus.ts). `"auto"` (default) moves
99
+ * focus to the incoming route's first heading / `main` landmark / subtree
100
+ * root on every navigation. `"off"` skips `focusIncomingRoute` entirely —
101
+ * the escape hatch for authors who own focus management themselves (custom
102
+ * transition choreography, embedded shells) and would otherwise fight the
103
+ * renderer for focus on every navigation.
104
+ */
105
+ routeFocus?: "auto" | "off";
106
+ }
107
+
48
108
  export class DOMRenderer {
49
109
  private container: HTMLElement;
50
110
  private nodes: Map<string, HTMLElement> = new Map();
@@ -58,20 +118,68 @@ export class DOMRenderer {
58
118
  private componentInstances = new Map<string, HypenModuleInstance>();
59
119
  private debugTracker: RerenderTracker;
60
120
 
121
+ /**
122
+ * Text elements whose template contains an `@{...}` binding, with the
123
+ * template pre-compiled into segments. Only these nodes are visited on
124
+ * state changes — static text never re-interpolates.
125
+ */
126
+ private textBindings = new Map<string, TextBinding>();
127
+
61
128
  // Canvas subtree routing — Canvas components get their own CanvasRenderer
62
129
  // and all descendant patches are forwarded to it instead of the DOM.
63
130
  private canvasRenderers = new Map<string, CanvasRenderer>();
64
131
  private canvasSubtreeMap = new Map<string, string>(); // nodeId → canvasRootId
132
+ // Parent/child links within canvas subtrees, so removes can prune the
133
+ // routing maps without scanning every entry ever recorded.
134
+ private canvasNodeChildren = new Map<string, Set<string>>();
135
+ private canvasNodeParent = new Map<string, string>();
65
136
  private canvasElements = new Map<string, HTMLCanvasElement>();
66
137
  /** Props stashed at create-time so we can forward them to CanvasRenderer */
67
138
  private pendingCreateProps = new Map<string, { elementType: string; props: Record<string, any> }>();
68
139
 
69
- constructor(container: HTMLElement, engine: IEngine, debugConfig?: Partial<DebugConfig>) {
140
+ /**
141
+ * Focus-restore memory for the Router subtree cache, keyed by detached
142
+ * subtree root id: the element that held focus when the route was
143
+ * detached, restored on cached re-`attach`. Entries are dropped on
144
+ * `remove` (Router LRU eviction) so focus restore can never target an
145
+ * evicted NodeId — the route-focus contract (see route-focus.ts).
146
+ */
147
+ private routeFocusMemory = new Map<string, HTMLElement>();
148
+
149
+ /** See `DOMRendererOptions.routeFocus` — `"off"` disables `focusIncomingRoute`. */
150
+ private routeFocus: "auto" | "off";
151
+
152
+ /** Ids created with `semantics.role === "dialog"`, so insert/remove hooks can act. */
153
+ private dialogIds = new Set<string>();
154
+ /**
155
+ * Open dialog id → the element that held focus before the dialog took it
156
+ * (the trigger), restored on dialog remove/detach when still connected.
157
+ * Presence of an entry means "already focused" — re-inserts (moves) of an
158
+ * open dialog never re-run the mount focus.
159
+ */
160
+ private dialogOpeners = new Map<string, HTMLElement | null>();
161
+ /**
162
+ * Dialog ids inserted during the current batch. Mount focus runs after the
163
+ * whole batch, because a dialog's focusable children may be inserted after
164
+ * the dialog's own insert patch.
165
+ */
166
+ private pendingDialogMounts: string[] = [];
167
+
168
+ constructor(
169
+ container: HTMLElement,
170
+ engine: IEngine,
171
+ debugConfig?: Partial<DebugConfig>,
172
+ options?: DOMRendererOptions,
173
+ ) {
70
174
  this.container = container;
71
175
  this.engine = engine;
72
176
  this.components = new ComponentRegistry();
73
177
  this.applicators = new ApplicatorRegistry();
74
178
  this.debugTracker = new RerenderTracker({ ...defaultDebugConfig, ...debugConfig });
179
+ this.routeFocus = options?.routeFocus ?? "auto";
180
+
181
+ // Inject the global reduced-motion + focus-visible stylesheet once.
182
+ ensureA11yStyles();
75
183
 
76
184
  // Match the canvas/iOS/Android renderers: text without an explicit
77
185
  // `.color()` defaults to black. Without this, DOM text would inherit
@@ -104,35 +212,76 @@ export class DOMRenderer {
104
212
  * the DOMRenderer or the owning CanvasRenderer.
105
213
  */
106
214
  applyPatches(patches: Patch[]): void {
107
- // Phase 1: scan inserts to discover new canvas-subtree members
108
- for (const patch of patches) {
109
- if (patch.type === "insert" || patch.type === "move") {
110
- const parentId = patch.parentId;
111
- const childId = patch.id;
112
- if (!parentId || !childId) continue;
113
-
114
- // Is the parent a canvas root or inside a canvas subtree?
115
- const canvasRootId =
116
- this.canvasRenderers.has(parentId) ? parentId
117
- : this.canvasSubtreeMap.get(parentId);
215
+ // Canvas routing only matters once a canvas root exists; the common
216
+ // no-canvas case applies the batch directly with no extra passes.
217
+ let canvasBatches: Map<string, Patch[]> | null = null;
218
+ let domPatches = patches;
219
+
220
+ if (this.canvasRenderers.size > 0 || this.canvasSubtreeMap.size > 0) {
221
+ // Phase 1: scan inserts to discover new canvas-subtree members
222
+ for (const patch of patches) {
223
+ if (patch.type === "insert" || patch.type === "move") {
224
+ const parentId = patch.parentId;
225
+ const childId = patch.id;
226
+ if (!parentId || !childId) continue;
227
+
228
+ // Is the parent a canvas root or inside a canvas subtree?
229
+ const canvasRootId =
230
+ this.canvasRenderers.has(parentId) ? parentId
231
+ : this.canvasSubtreeMap.get(parentId);
232
+ if (canvasRootId) {
233
+ this.registerCanvasMember(childId, parentId, canvasRootId);
234
+ }
235
+ }
236
+ }
237
+
238
+ // Phase 2: route patches
239
+ canvasBatches = new Map<string, Patch[]>();
240
+ domPatches = [];
241
+
242
+ for (const patch of patches) {
243
+ const canvasRootId = this.getCanvasRouteTarget(patch);
118
244
  if (canvasRootId) {
119
- this.canvasSubtreeMap.set(childId, canvasRootId);
245
+ let batch = canvasBatches.get(canvasRootId);
246
+ if (!batch) { batch = []; canvasBatches.set(canvasRootId, batch); }
247
+ batch.push(patch);
248
+ if (patch.type === "remove" && patch.id) {
249
+ // Removed canvas nodes never come back (engine node ids are
250
+ // never reused), so drop their routing entries now.
251
+ this.pruneCanvasSubtree(patch.id);
252
+ }
253
+ } else {
254
+ domPatches.push(patch);
120
255
  }
121
256
  }
122
257
  }
123
258
 
124
- // Phase 2: route patches
125
- const canvasBatches = new Map<string, Patch[]>();
126
- const domPatches: Patch[] = [];
127
-
128
- for (const patch of patches) {
129
- const canvasRootId = this.getCanvasRouteTarget(patch);
130
- if (canvasRootId) {
131
- let batch = canvasBatches.get(canvasRootId);
132
- if (!batch) { batch = []; canvasBatches.set(canvasRootId, batch); }
133
- batch.push(patch);
134
- } else {
135
- domPatches.push(patch);
259
+ // Route-change detection for the focus contract: a batch with a detach
260
+ // (route leaving) plus incoming subtree roots (attach of a cached route,
261
+ // or a fresh top-level create+insert) is a navigation. Initial renders
262
+ // have no detach, so they never steal focus.
263
+ const isNavigation = domPatches.some((p) => p.type === "detach");
264
+ const incomingRoots: string[] = [];
265
+ if (isNavigation) {
266
+ const createdInBatch = new Set<string>();
267
+ for (const p of domPatches) {
268
+ if (p.type === "create" && p.id) {
269
+ createdInBatch.add(p.id);
270
+ }
271
+ }
272
+ for (const p of domPatches) {
273
+ if (p.type === "attach" && p.id) {
274
+ incomingRoots.push(p.id);
275
+ } else if (
276
+ p.type === "insert" &&
277
+ p.id &&
278
+ createdInBatch.has(p.id) &&
279
+ (!p.parentId || !createdInBatch.has(p.parentId))
280
+ ) {
281
+ // A freshly-built subtree root: created in this batch, inserted
282
+ // under a parent that predates the batch (the router's slot).
283
+ incomingRoots.push(p.id);
284
+ }
136
285
  }
137
286
  }
138
287
 
@@ -142,12 +291,59 @@ export class DOMRenderer {
142
291
  }
143
292
 
144
293
  // Forward canvas-subtree patches to their CanvasRenderer instances
145
- for (const [rootId, batch] of canvasBatches) {
146
- const renderer = this.canvasRenderers.get(rootId);
147
- if (renderer) {
148
- renderer.applyPatches(batch);
294
+ if (canvasBatches) {
295
+ for (const [rootId, batch] of canvasBatches) {
296
+ const renderer = this.canvasRenderers.get(rootId);
297
+ if (renderer) {
298
+ renderer.applyPatches(batch);
299
+ }
149
300
  }
150
301
  }
302
+
303
+ if (isNavigation && incomingRoots.length > 0 && this.routeFocus !== "off") {
304
+ this.focusIncomingRoute(incomingRoots[0]!);
305
+ }
306
+
307
+ // Dialog mount focus runs last: a dialog opened by this batch takes
308
+ // focus even when the batch was also a navigation (modal wins).
309
+ this.flushDialogMounts();
310
+ }
311
+
312
+ /**
313
+ * Apply the dialog focus contract to dialogs inserted by the finished
314
+ * batch: focus the first focusable descendant, else the dialog itself, and
315
+ * remember the previously-focused element (the trigger) for restore on
316
+ * close. Deferred to batch end so descendants inserted after the dialog's
317
+ * own insert patch are visible to the focus search.
318
+ */
319
+ private flushDialogMounts(): void {
320
+ if (this.pendingDialogMounts.length === 0) return;
321
+ const pending = this.pendingDialogMounts;
322
+ this.pendingDialogMounts = [];
323
+ for (const id of pending) {
324
+ if (this.dialogOpeners.has(id)) continue; // already open — a move, not a mount
325
+ const dialog = this.nodes.get(id);
326
+ if (!dialog) continue;
327
+ this.dialogOpeners.set(id, focusDialogOnOpen(dialog));
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Apply the route-focus contract to a freshly-shown route subtree: restore
333
+ * the remembered focus of a cached route when it is still inside the
334
+ * subtree, otherwise land on the first heading / main landmark / the
335
+ * subtree root (see route-focus.ts for the full contract).
336
+ */
337
+ private focusIncomingRoute(rootId: string): void {
338
+ const root = this.nodes.get(rootId);
339
+ if (!root) return;
340
+
341
+ const remembered = this.routeFocusMemory.get(rootId);
342
+ const target =
343
+ remembered && (root === remembered || root.contains?.(remembered))
344
+ ? remembered
345
+ : findRouteFocusTarget(root);
346
+ focusRouteTarget(target);
151
347
  }
152
348
 
153
349
  /**
@@ -182,6 +378,46 @@ export class DOMRenderer {
182
378
  }
183
379
  }
184
380
 
381
+ /**
382
+ * Record a node as a member of a canvas subtree, tracking its parent link
383
+ * so a later `remove` can prune the whole subtree from the routing maps.
384
+ */
385
+ private registerCanvasMember(childId: string, parentId: string, canvasRootId: string): void {
386
+ this.canvasSubtreeMap.set(childId, canvasRootId);
387
+ const prevParent = this.canvasNodeParent.get(childId);
388
+ if (prevParent !== undefined && prevParent !== parentId) {
389
+ this.canvasNodeChildren.get(prevParent)?.delete(childId);
390
+ }
391
+ this.canvasNodeParent.set(childId, parentId);
392
+ let siblings = this.canvasNodeChildren.get(parentId);
393
+ if (!siblings) {
394
+ siblings = new Set();
395
+ this.canvasNodeChildren.set(parentId, siblings);
396
+ }
397
+ siblings.add(childId);
398
+ }
399
+
400
+ /**
401
+ * Drop a removed canvas node and all of its descendants from the canvas
402
+ * routing maps. The engine emits `remove` only for the subtree root, so
403
+ * descendants are walked via the tracked child links.
404
+ */
405
+ private pruneCanvasSubtree(id: string): void {
406
+ const children = this.canvasNodeChildren.get(id);
407
+ if (children) {
408
+ this.canvasNodeChildren.delete(id);
409
+ for (const childId of children) {
410
+ this.pruneCanvasSubtree(childId);
411
+ }
412
+ }
413
+ this.canvasSubtreeMap.delete(id);
414
+ const parentId = this.canvasNodeParent.get(id);
415
+ if (parentId !== undefined) {
416
+ this.canvasNodeParent.delete(id);
417
+ this.canvasNodeChildren.get(parentId)?.delete(id);
418
+ }
419
+ }
420
+
185
421
  /**
186
422
  * Update state and interpolate text content
187
423
  */
@@ -201,42 +437,64 @@ export class DOMRenderer {
201
437
  }
202
438
 
203
439
  /**
204
- * Interpolate state values in all text elements
440
+ * Interpolate state values in all binding-bearing text elements
205
441
  */
206
442
  private interpolateAllText(): void {
207
- for (const [id, element] of this.nodes.entries()) {
208
- if (element.dataset.hypenType === "text" && element.dataset.textTemplate) {
209
- const template = element.dataset.textTemplate;
210
- const interpolated = this.interpolateText(template, this.currentState);
211
-
212
- // Track re-render if text actually changed
213
- const currentText = element.textContent;
214
- if (currentText !== interpolated) {
215
- this.debugTracker.trackRerender(id, element, "interpolate");
216
- }
217
-
443
+ for (const [id, binding] of this.textBindings) {
444
+ const interpolated = this.interpolateSegments(binding.segments, this.currentState);
445
+ const element = binding.element;
446
+ if (element.textContent !== interpolated) {
447
+ this.debugTracker.trackRerender(id, element, "interpolate");
218
448
  element.textContent = interpolated;
219
449
  }
220
450
  }
221
451
  }
222
452
 
223
453
  /**
224
- * Interpolate state values in text template.
454
+ * Resolve a compiled template against the current state.
225
455
  *
226
- * Bindings use the `@{state.path}` syntax (matching the engine's parser).
456
+ * Bindings use the `@{state.path}` syntax (matching the engine's parser);
457
+ * unresolvable bindings render as their original `@{...}` text.
227
458
  */
228
- private interpolateText(template: string, state: Record<string, any>): string {
229
- return template.replace(/@\{([^}]+)\}/g, (match, path) => {
459
+ private interpolateSegments(segments: TemplateSegment[], state: Record<string, any>): string {
460
+ let result = "";
461
+ for (const segment of segments) {
462
+ if (typeof segment === "string") {
463
+ result += segment;
464
+ continue;
465
+ }
230
466
  try {
231
- const value = path.split('.').reduce((obj: any, key: string) => {
232
- if (key === 'state') return state;
233
- return obj?.[key];
234
- }, state);
235
- return value !== undefined ? String(value) : match;
467
+ let value: any = state;
468
+ for (const key of segment.keys) {
469
+ value = key === "state" ? state : value?.[key];
470
+ }
471
+ result += value !== undefined ? String(value) : segment.match;
236
472
  } catch {
237
- return match;
473
+ result += segment.match;
474
+ }
475
+ }
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Keep the binding index in sync with the element's stored template:
481
+ * register text elements whose template contains an `@{...}` binding,
482
+ * drop everything else.
483
+ */
484
+ private syncTextBinding(id: string, element: HTMLElement): void {
485
+ const template = element.dataset.textTemplate;
486
+ if (element.dataset.hypenType === "text" && template && template.includes("@{")) {
487
+ const existing = this.textBindings.get(id);
488
+ if (!existing || existing.template !== template || existing.element !== element) {
489
+ this.textBindings.set(id, {
490
+ element,
491
+ template,
492
+ segments: compileTextTemplate(template),
493
+ });
238
494
  }
239
- });
495
+ } else {
496
+ this.textBindings.delete(id);
497
+ }
240
498
  }
241
499
 
242
500
  /**
@@ -250,7 +508,7 @@ export class DOMRenderer {
250
508
 
251
509
  switch (patch.type) {
252
510
  case "create":
253
- this.onCreate(id!, elementType ?? "container", patch.props || {});
511
+ this.onCreate(id!, elementType ?? "container", patch.props || {}, patch.semantics);
254
512
  break;
255
513
  case "setProp":
256
514
  this.onSetProp(id!, patch.name!, patch.value);
@@ -276,13 +534,33 @@ export class DOMRenderer {
276
534
  case "attach":
277
535
  this.onInsert(parentId!, id!, beforeId);
278
536
  break;
537
+ case "setSemantics":
538
+ this.onSetSemantics(id!, patch.semantics);
539
+ break;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Reactive accessibility update: re-run the create-time semantics
545
+ * translation on the live element with the node's complete re-resolved
546
+ * block. `applySemantics` is idempotent and clears attributes it set on a
547
+ * previous pass that the new block no longer produces. This is the sole
548
+ * ARIA writer for bound self-state — the SetProp that accompanies a bound
549
+ * `.expanded(@state.open)` change does not touch ARIA attributes.
550
+ */
551
+ private onSetSemantics(id: string, semantics?: Semantics): void {
552
+ const element = this.nodes.get(id);
553
+ if (!element) {
554
+ log.warn(`setSemantics: element ${id} not found`);
555
+ return;
279
556
  }
557
+ applySemantics(element, semantics);
280
558
  }
281
559
 
282
560
  /**
283
561
  * Create a new element
284
562
  */
285
- private onCreate(id: string, elementType: string, props: Record<string, any> | Map<string, any>): void {
563
+ private onCreate(id: string, elementType: string, props: Record<string, any> | Map<string, any>, semantics?: Semantics): void {
286
564
  const propsObj = props instanceof Map ? Object.fromEntries(props) : props;
287
565
 
288
566
  let element = this.components.createElement(elementType, propsObj);
@@ -302,14 +580,60 @@ export class DOMRenderer {
302
580
  element.dataset.hypenId = id;
303
581
  setEngine(element, this.engine);
304
582
 
583
+ // Slot marker: `.slot("name")` lowers to the `slot.0` prop. The engine
584
+ // resolves slots during component expansion, but container components
585
+ // that own their children natively (HypenApp's loading/error slots)
586
+ // need to identify slotted children at the DOM level.
587
+ const slotName = propsObj["slot.0"] ?? propsObj.slot;
588
+ if (typeof slotName === "string" && slotName) {
589
+ element.dataset.hypenSlot = slotName;
590
+ }
591
+
592
+ // Apply engine-derived accessibility semantics (role, …) before styling
593
+ // props, so a redundant-role check sees the final host tag.
594
+ applySemantics(element, semantics);
595
+
596
+ // Dialog-like containers trap keyboard focus so Tab cycles within the
597
+ // dialog rather than escaping to the page behind it. Mount focus and
598
+ // restore-to-trigger run on insert/remove (tracked via dialogIds) — the
599
+ // element isn't in the document yet at create time. Escape closes only
600
+ // when the author declared an onClose action; without one, closing is
601
+ // app state and Escape does nothing.
602
+ if (semantics?.role === "dialog") {
603
+ makeFocusTrap(element);
604
+ this.dialogIds.add(id);
605
+ const onClose = propsObj.onClose ?? propsObj["onClose.0"];
606
+ if (onClose) {
607
+ installDialogEscape(element, onClose);
608
+ }
609
+ }
610
+
611
+ // Tablists get the WAI-ARIA roving-tabindex keyboard contract: arrows
612
+ // move focus among the role="tab" children, Home/End jump to the ends,
613
+ // only the focused tab stays in the page Tab order, and printable
614
+ // characters typeahead to the next matching tab.
615
+ if (semantics?.role === "tablist") {
616
+ makeRovingTablist(element);
617
+ }
618
+
619
+ // Listboxes get the same roving + typeahead contract over their
620
+ // role="option" children (no-op on a native <select> host, where the
621
+ // browser owns the keyboard behaviour).
622
+ if (semantics?.role === "listbox") {
623
+ makeRovingListbox(element);
624
+ }
625
+
305
626
  this.applicators.applyAll(element, propsObj);
306
627
 
307
- // Actionable components: wire "action" prop as onClick
628
+ // Actionable components: wire "action" prop as onClick, and make
629
+ // non-native hosts (e.g. an actionable Card div) keyboard-operable.
308
630
  if (propsObj.action && ACTIONABLE_TYPES.has(elementType.toLowerCase())) {
309
631
  this.applicators.apply(element, "onClick", propsObj.action);
632
+ makeKeyboardActivatable(element, propsObj.action);
310
633
  }
311
634
 
312
635
  this.nodes.set(id, element);
636
+ this.syncTextBinding(id, element);
313
637
  this.debugTracker.trackRerender(id, element, `create:${elementType}`);
314
638
 
315
639
  // Canvas component: create a CanvasRenderer for its subtree
@@ -391,6 +715,7 @@ export class DOMRenderer {
391
715
  // If current template isn't a template, keep it in sync.
392
716
  element.dataset.textTemplate = nextText;
393
717
  }
718
+ this.syncTextBinding(id, element);
394
719
  log.debug(`Updated text content: "${value}"`);
395
720
  return;
396
721
  }
@@ -398,6 +723,7 @@ export class DOMRenderer {
398
723
  // Actionable components: wire "action" prop as onClick
399
724
  if (name === "action" && ACTIONABLE_TYPES.has(element.dataset.hypenType || "")) {
400
725
  this.applicators.apply(element, "onClick", value);
726
+ makeKeyboardActivatable(element, value);
401
727
  return;
402
728
  }
403
729
 
@@ -455,11 +781,16 @@ export class DOMRenderer {
455
781
  const parent = parentId === "root" ? this.container : this.nodes.get(parentId);
456
782
  const child = this.nodes.get(id);
457
783
 
458
- log.debug(`Inserting ${id} into ${parentId}`, {
459
- parent: parent ? `${parent.tagName}#${parent.id || 'no-id'}` : 'null',
460
- child: child ? `${child.tagName}#${child.id || 'no-id'}` : 'null',
461
- childText: child?.textContent?.substring(0, 20)
462
- });
784
+ // Gated up front: building the payload reads `textContent`, which
785
+ // serializes the whole subtree's text — too costly to pay when debug
786
+ // logging is off.
787
+ if (isDebugMode()) {
788
+ log.debug(`Inserting ${id} into ${parentId}`, {
789
+ parent: parent ? `${parent.tagName}#${parent.id || 'no-id'}` : 'null',
790
+ child: child ? `${child.tagName}#${child.id || 'no-id'}` : 'null',
791
+ childText: child?.textContent?.substring(0, 20)
792
+ });
793
+ }
463
794
 
464
795
  if (!parent || !child) return;
465
796
 
@@ -479,6 +810,13 @@ export class DOMRenderer {
479
810
  parent.appendChild(child);
480
811
  }
481
812
  }
813
+
814
+ // Dialog entering the document (insert or cached re-attach): queue mount
815
+ // focus for the end of the batch (flushDialogMounts). Already-open
816
+ // dialogs are filtered there, so moves never re-focus.
817
+ if (this.dialogIds.has(id)) {
818
+ this.pendingDialogMounts.push(id);
819
+ }
482
820
  }
483
821
 
484
822
  /**
@@ -509,10 +847,26 @@ export class DOMRenderer {
509
847
  const element = this.nodes.get(id);
510
848
  if (!element) return;
511
849
 
850
+ // Remember where focus was inside the leaving route, so a cached
851
+ // re-`attach` of this subtree can restore it (route-focus contract).
852
+ const active = (element.ownerDocument?.activeElement ??
853
+ (typeof document !== "undefined" ? document.activeElement : null)) as HTMLElement | null;
854
+ if (active && (element === active || element.contains?.(active))) {
855
+ this.routeFocusMemory.set(id, active);
856
+ }
857
+
512
858
  if (element.parentNode) {
513
859
  element.parentNode.removeChild(element);
514
860
  }
515
861
 
862
+ // Any open dialog inside the detached subtree just left the document:
863
+ // restore its trigger (a trigger detached along with it fails the
864
+ // connectivity check and is skipped — the route-focus contract owns
865
+ // focus for the incoming route). Runs after the DOM unlink so the
866
+ // connectivity check reflects the detach. dialogIds keeps the id — a
867
+ // cached re-attach re-runs mount focus.
868
+ this.restoreDialogsWithin(element);
869
+
516
870
  // If the root gets detached (unlikely but defensible), clear
517
871
  // rootId so a later attach can re-root cleanly.
518
872
  if (this.rootId === id) {
@@ -520,6 +874,23 @@ export class DOMRenderer {
520
874
  }
521
875
  }
522
876
 
877
+ /**
878
+ * Close-side of the dialog focus contract: every open dialog at-or-under
879
+ * `subtree` has left the document — restore focus to its remembered
880
+ * trigger (when still connected) and drop the open entry so a later
881
+ * insert/attach counts as a fresh mount.
882
+ */
883
+ private restoreDialogsWithin(subtree: HTMLElement): void {
884
+ if (this.dialogOpeners.size === 0) return;
885
+ for (const [dialogId, opener] of this.dialogOpeners) {
886
+ const dialog = this.nodes.get(dialogId);
887
+ if (!dialog || subtree === dialog || subtree.contains?.(dialog)) {
888
+ this.dialogOpeners.delete(dialogId);
889
+ if (dialog) restoreDialogFocus(dialog, opener);
890
+ }
891
+ }
892
+ }
893
+
523
894
  /**
524
895
  * Remove an element from the tree
525
896
  */
@@ -533,8 +904,12 @@ export class DOMRenderer {
533
904
  this.canvasRenderers.delete(id);
534
905
  this.canvasElements.delete(id);
535
906
  // Remove all subtree entries pointing to this canvas root
536
- for (const [nodeId, rootId] of this.canvasSubtreeMap) {
537
- if (rootId === id) this.canvasSubtreeMap.delete(nodeId);
907
+ const members = this.canvasNodeChildren.get(id);
908
+ if (members) {
909
+ this.canvasNodeChildren.delete(id);
910
+ for (const childId of members) {
911
+ this.pruneCanvasSubtree(childId);
912
+ }
538
913
  }
539
914
  }
540
915
 
@@ -545,7 +920,17 @@ export class DOMRenderer {
545
920
  element.parentNode.removeChild(element);
546
921
  }
547
922
 
923
+ // Restore-to-trigger for any open dialog in the removed subtree, before
924
+ // the node map forgets it. The removed subtree is gone for good, so the
925
+ // dialog id is dropped too.
926
+ this.restoreDialogsWithin(element);
927
+
548
928
  this.nodes.delete(id);
929
+ this.textBindings.delete(id);
930
+ this.dialogIds.delete(id);
931
+ // Router LRU eviction: this subtree is gone for good — focus restore
932
+ // must never target it again (route-focus contract).
933
+ this.routeFocusMemory.delete(id);
549
934
 
550
935
  if (this.rootId === id) {
551
936
  this.rootId = null;
@@ -570,6 +955,8 @@ export class DOMRenderer {
570
955
  this.canvasRenderers.clear();
571
956
  this.canvasElements.clear();
572
957
  this.canvasSubtreeMap.clear();
958
+ this.canvasNodeChildren.clear();
959
+ this.canvasNodeParent.clear();
573
960
 
574
961
  // Dispose all element resources before clearing
575
962
  for (const element of this.nodes.values()) {
@@ -577,7 +964,12 @@ export class DOMRenderer {
577
964
  }
578
965
  this.container.innerHTML = "";
579
966
  this.nodes.clear();
967
+ this.textBindings.clear();
580
968
  this.rootId = null;
969
+ this.dialogIds.clear();
970
+ this.dialogOpeners.clear();
971
+ this.pendingDialogMounts = [];
972
+ this.routeFocusMemory.clear();
581
973
  }
582
974
 
583
975
  /**