@hypen-space/web 0.4.980 → 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 (229) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/accessibility.d.ts +118 -11
  3. package/dist/canvas/accessibility.js +470 -108
  4. package/dist/canvas/accessibility.js.map +1 -1
  5. package/dist/canvas/dispatch.d.ts +24 -0
  6. package/dist/canvas/dispatch.js +62 -0
  7. package/dist/canvas/dispatch.js.map +1 -0
  8. package/dist/canvas/editing.d.ts +178 -0
  9. package/dist/canvas/editing.js +590 -0
  10. package/dist/canvas/editing.js.map +1 -0
  11. package/dist/canvas/events.d.ts +28 -23
  12. package/dist/canvas/events.js +90 -103
  13. package/dist/canvas/events.js.map +1 -1
  14. package/dist/canvas/focus.d.ts +78 -0
  15. package/dist/canvas/focus.js +182 -0
  16. package/dist/canvas/focus.js.map +1 -0
  17. package/dist/canvas/index.d.ts +3 -2
  18. package/dist/canvas/index.js +3 -2
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/canvas/layout.js +68 -98
  21. package/dist/canvas/layout.js.map +1 -1
  22. package/dist/canvas/paint.d.ts +16 -3
  23. package/dist/canvas/paint.js +449 -110
  24. package/dist/canvas/paint.js.map +1 -1
  25. package/dist/canvas/renderer.d.ts +28 -4
  26. package/dist/canvas/renderer.js +201 -81
  27. package/dist/canvas/renderer.js.map +1 -1
  28. package/dist/canvas/selection.js +31 -177
  29. package/dist/canvas/selection.js.map +1 -1
  30. package/dist/canvas/text-geometry.d.ts +72 -0
  31. package/dist/canvas/text-geometry.js +244 -0
  32. package/dist/canvas/text-geometry.js.map +1 -0
  33. package/dist/canvas/text.js +44 -18
  34. package/dist/canvas/text.js.map +1 -1
  35. package/dist/canvas/types.d.ts +10 -1
  36. package/dist/canvas/utils.d.ts +0 -23
  37. package/dist/canvas/utils.js +3 -67
  38. package/dist/canvas/utils.js.map +1 -1
  39. package/dist/canvas/variants.d.ts +52 -0
  40. package/dist/canvas/variants.js +156 -0
  41. package/dist/canvas/variants.js.map +1 -0
  42. package/dist/dom/a11y-styles.d.ts +20 -0
  43. package/dist/dom/a11y-styles.js +61 -0
  44. package/dist/dom/a11y-styles.js.map +1 -0
  45. package/dist/dom/applicators/aria.d.ts +19 -0
  46. package/dist/dom/applicators/aria.js +36 -0
  47. package/dist/dom/applicators/aria.js.map +1 -0
  48. package/dist/dom/applicators/events.d.ts +7 -0
  49. package/dist/dom/applicators/events.js +36 -8
  50. package/dist/dom/applicators/events.js.map +1 -1
  51. package/dist/dom/applicators/font.d.ts +11 -6
  52. package/dist/dom/applicators/font.js +16 -27
  53. package/dist/dom/applicators/font.js.map +1 -1
  54. package/dist/dom/applicators/index.js +51 -45
  55. package/dist/dom/applicators/index.js.map +1 -1
  56. package/dist/dom/canvas/index.js +2 -2
  57. package/dist/dom/canvas/index.js.map +1 -1
  58. package/dist/dom/components/app.js +2 -2
  59. package/dist/dom/components/app.js.map +1 -1
  60. package/dist/dom/components/audio.js +2 -2
  61. package/dist/dom/components/audio.js.map +1 -1
  62. package/dist/dom/components/avatar.js +3 -4
  63. package/dist/dom/components/avatar.js.map +1 -1
  64. package/dist/dom/components/badge.js +3 -4
  65. package/dist/dom/components/badge.js.map +1 -1
  66. package/dist/dom/components/button.js +2 -2
  67. package/dist/dom/components/button.js.map +1 -1
  68. package/dist/dom/components/card.js +2 -2
  69. package/dist/dom/components/card.js.map +1 -1
  70. package/dist/dom/components/center.js +2 -2
  71. package/dist/dom/components/center.js.map +1 -1
  72. package/dist/dom/components/checkbox.js +4 -5
  73. package/dist/dom/components/checkbox.js.map +1 -1
  74. package/dist/dom/components/column.js +2 -2
  75. package/dist/dom/components/column.js.map +1 -1
  76. package/dist/dom/components/container.js +2 -2
  77. package/dist/dom/components/container.js.map +1 -1
  78. package/dist/dom/components/divider.js +2 -2
  79. package/dist/dom/components/divider.js.map +1 -1
  80. package/dist/dom/components/grid.js +10 -10
  81. package/dist/dom/components/grid.js.map +1 -1
  82. package/dist/dom/components/heading.js +4 -6
  83. package/dist/dom/components/heading.js.map +1 -1
  84. package/dist/dom/components/hypenapp.d.ts +25 -1
  85. package/dist/dom/components/hypenapp.js +215 -247
  86. package/dist/dom/components/hypenapp.js.map +1 -1
  87. package/dist/dom/components/icon.js +4 -5
  88. package/dist/dom/components/icon.js.map +1 -1
  89. package/dist/dom/components/image.js +2 -2
  90. package/dist/dom/components/image.js.map +1 -1
  91. package/dist/dom/components/index.d.ts +2 -2
  92. package/dist/dom/components/index.js +9 -2
  93. package/dist/dom/components/index.js.map +1 -1
  94. package/dist/dom/components/input.js +2 -2
  95. package/dist/dom/components/input.js.map +1 -1
  96. package/dist/dom/components/link.js +2 -2
  97. package/dist/dom/components/link.js.map +1 -1
  98. package/dist/dom/components/list.js +2 -2
  99. package/dist/dom/components/list.js.map +1 -1
  100. package/dist/dom/components/paragraph.js +3 -4
  101. package/dist/dom/components/paragraph.js.map +1 -1
  102. package/dist/dom/components/progressbar.js +3 -3
  103. package/dist/dom/components/progressbar.js.map +1 -1
  104. package/dist/dom/components/route.js +2 -2
  105. package/dist/dom/components/route.js.map +1 -1
  106. package/dist/dom/components/router.js +2 -2
  107. package/dist/dom/components/router.js.map +1 -1
  108. package/dist/dom/components/row.js +10 -10
  109. package/dist/dom/components/row.js.map +1 -1
  110. package/dist/dom/components/select.js +3 -4
  111. package/dist/dom/components/select.js.map +1 -1
  112. package/dist/dom/components/slider.js +2 -2
  113. package/dist/dom/components/slider.js.map +1 -1
  114. package/dist/dom/components/spacer.js +2 -2
  115. package/dist/dom/components/spacer.js.map +1 -1
  116. package/dist/dom/components/spinner.js +27 -13
  117. package/dist/dom/components/spinner.js.map +1 -1
  118. package/dist/dom/components/stack.js +10 -10
  119. package/dist/dom/components/stack.js.map +1 -1
  120. package/dist/dom/components/switch.js +5 -6
  121. package/dist/dom/components/switch.js.map +1 -1
  122. package/dist/dom/components/tabs.d.ts +27 -0
  123. package/dist/dom/components/tabs.js +69 -0
  124. package/dist/dom/components/tabs.js.map +1 -0
  125. package/dist/dom/components/text.js +4 -7
  126. package/dist/dom/components/text.js.map +1 -1
  127. package/dist/dom/components/textarea.js +2 -2
  128. package/dist/dom/components/textarea.js.map +1 -1
  129. package/dist/dom/components/video.js +2 -2
  130. package/dist/dom/components/video.js.map +1 -1
  131. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  132. package/dist/dom/components/visuallyhidden.js +26 -0
  133. package/dist/dom/components/visuallyhidden.js.map +1 -0
  134. package/dist/dom/debug.js +6 -8
  135. package/dist/dom/debug.js.map +1 -1
  136. package/dist/dom/index.d.ts +0 -1
  137. package/dist/dom/index.js +0 -1
  138. package/dist/dom/index.js.map +1 -1
  139. package/dist/dom/operability.d.ts +100 -0
  140. package/dist/dom/operability.js +298 -0
  141. package/dist/dom/operability.js.map +1 -0
  142. package/dist/dom/renderer.d.ts +98 -5
  143. package/dist/dom/renderer.js +400 -66
  144. package/dist/dom/renderer.js.map +1 -1
  145. package/dist/dom/route-focus.d.ts +42 -0
  146. package/dist/dom/route-focus.js +88 -0
  147. package/dist/dom/route-focus.js.map +1 -0
  148. package/dist/dom/semantics.d.ts +35 -0
  149. package/dist/dom/semantics.js +184 -0
  150. package/dist/dom/semantics.js.map +1 -0
  151. package/dist/variants.d.ts +90 -0
  152. package/dist/variants.js +206 -0
  153. package/dist/variants.js.map +1 -0
  154. package/package.json +2 -2
  155. package/src/canvas/QUICKSTART.md +16 -11
  156. package/src/canvas/README.md +63 -34
  157. package/src/canvas/accessibility.ts +507 -115
  158. package/src/canvas/dispatch.ts +78 -0
  159. package/src/canvas/editing.ts +697 -0
  160. package/src/canvas/events.ts +104 -115
  161. package/src/canvas/focus.ts +216 -0
  162. package/src/canvas/index.ts +8 -9
  163. package/src/canvas/layout.ts +68 -103
  164. package/src/canvas/paint.ts +509 -109
  165. package/src/canvas/renderer.ts +239 -99
  166. package/src/canvas/selection.ts +52 -210
  167. package/src/canvas/text-geometry.ts +311 -0
  168. package/src/canvas/text.ts +45 -18
  169. package/src/canvas/types.ts +28 -1
  170. package/src/canvas/utils.ts +3 -69
  171. package/src/canvas/variants.ts +172 -0
  172. package/src/dom/README.md +8 -7
  173. package/src/dom/a11y-styles.ts +65 -0
  174. package/src/dom/applicators/aria.ts +41 -0
  175. package/src/dom/applicators/events.ts +39 -7
  176. package/src/dom/applicators/font.ts +16 -29
  177. package/src/dom/applicators/index.ts +61 -46
  178. package/src/dom/canvas/index.ts +2 -2
  179. package/src/dom/components/app.ts +2 -2
  180. package/src/dom/components/audio.ts +2 -2
  181. package/src/dom/components/avatar.ts +3 -4
  182. package/src/dom/components/badge.ts +3 -4
  183. package/src/dom/components/button.ts +2 -2
  184. package/src/dom/components/card.ts +2 -2
  185. package/src/dom/components/center.ts +2 -2
  186. package/src/dom/components/checkbox.ts +4 -5
  187. package/src/dom/components/column.ts +2 -2
  188. package/src/dom/components/container.ts +2 -2
  189. package/src/dom/components/divider.ts +2 -2
  190. package/src/dom/components/grid.ts +10 -10
  191. package/src/dom/components/heading.ts +4 -6
  192. package/src/dom/components/hypenapp.ts +246 -274
  193. package/src/dom/components/icon.ts +4 -5
  194. package/src/dom/components/image.ts +2 -2
  195. package/src/dom/components/index.ts +10 -3
  196. package/src/dom/components/input.ts +2 -2
  197. package/src/dom/components/link.ts +2 -2
  198. package/src/dom/components/list.ts +2 -2
  199. package/src/dom/components/paragraph.ts +3 -4
  200. package/src/dom/components/progressbar.ts +3 -3
  201. package/src/dom/components/route.ts +2 -2
  202. package/src/dom/components/router.ts +2 -2
  203. package/src/dom/components/row.ts +10 -10
  204. package/src/dom/components/select.ts +3 -4
  205. package/src/dom/components/slider.ts +2 -2
  206. package/src/dom/components/spacer.ts +2 -2
  207. package/src/dom/components/spinner.ts +34 -16
  208. package/src/dom/components/stack.ts +10 -10
  209. package/src/dom/components/switch.ts +5 -6
  210. package/src/dom/components/tabs.ts +76 -0
  211. package/src/dom/components/text.ts +4 -7
  212. package/src/dom/components/textarea.ts +2 -2
  213. package/src/dom/components/video.ts +2 -2
  214. package/src/dom/components/visuallyhidden.ts +30 -0
  215. package/src/dom/debug.ts +6 -8
  216. package/src/dom/index.ts +0 -8
  217. package/src/dom/operability.ts +312 -0
  218. package/src/dom/renderer.ts +457 -66
  219. package/src/dom/route-focus.ts +98 -0
  220. package/src/dom/semantics.ts +199 -0
  221. package/src/variants.ts +249 -0
  222. package/dist/canvas/input.d.ts +0 -76
  223. package/dist/canvas/input.js +0 -207
  224. package/dist/canvas/input.js.map +0 -1
  225. package/dist/client.d.ts +0 -36
  226. package/dist/client.js +0 -47
  227. package/dist/client.js.map +0 -1
  228. package/src/canvas/input.ts +0 -251
  229. package/src/client.ts +0 -88
@@ -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,23 +534,42 @@ 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
- const doc = this.container.ownerDocument as Document;
289
- let element = this.components.createElement(elementType, propsObj, doc);
566
+ let element = this.components.createElement(elementType, propsObj);
290
567
 
291
568
  if (!element) {
292
569
  // For unknown component types, create a transparent container (div).
293
570
  // This handles module wrappers (like "App") and custom components
294
571
  // that aren't registered but should act as layout containers.
295
- const fallback = doc.createElement("div");
572
+ const fallback = document.createElement("div");
296
573
  fallback.dataset.hypenType = elementType.toLowerCase();
297
574
  fallback.style.display = "contents"; // Make container transparent in layout
298
575
  element = fallback;
@@ -303,14 +580,60 @@ export class DOMRenderer {
303
580
  element.dataset.hypenId = id;
304
581
  setEngine(element, this.engine);
305
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
+
306
626
  this.applicators.applyAll(element, propsObj);
307
627
 
308
- // 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.
309
630
  if (propsObj.action && ACTIONABLE_TYPES.has(elementType.toLowerCase())) {
310
631
  this.applicators.apply(element, "onClick", propsObj.action);
632
+ makeKeyboardActivatable(element, propsObj.action);
311
633
  }
312
634
 
313
635
  this.nodes.set(id, element);
636
+ this.syncTextBinding(id, element);
314
637
  this.debugTracker.trackRerender(id, element, `create:${elementType}`);
315
638
 
316
639
  // Canvas component: create a CanvasRenderer for its subtree
@@ -392,6 +715,7 @@ export class DOMRenderer {
392
715
  // If current template isn't a template, keep it in sync.
393
716
  element.dataset.textTemplate = nextText;
394
717
  }
718
+ this.syncTextBinding(id, element);
395
719
  log.debug(`Updated text content: "${value}"`);
396
720
  return;
397
721
  }
@@ -399,6 +723,7 @@ export class DOMRenderer {
399
723
  // Actionable components: wire "action" prop as onClick
400
724
  if (name === "action" && ACTIONABLE_TYPES.has(element.dataset.hypenType || "")) {
401
725
  this.applicators.apply(element, "onClick", value);
726
+ makeKeyboardActivatable(element, value);
402
727
  return;
403
728
  }
404
729
 
@@ -456,11 +781,16 @@ export class DOMRenderer {
456
781
  const parent = parentId === "root" ? this.container : this.nodes.get(parentId);
457
782
  const child = this.nodes.get(id);
458
783
 
459
- log.debug(`Inserting ${id} into ${parentId}`, {
460
- parent: parent ? `${parent.tagName}#${parent.id || 'no-id'}` : 'null',
461
- child: child ? `${child.tagName}#${child.id || 'no-id'}` : 'null',
462
- childText: child?.textContent?.substring(0, 20)
463
- });
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
+ }
464
794
 
465
795
  if (!parent || !child) return;
466
796
 
@@ -480,6 +810,13 @@ export class DOMRenderer {
480
810
  parent.appendChild(child);
481
811
  }
482
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
+ }
483
820
  }
484
821
 
485
822
  /**
@@ -510,10 +847,26 @@ export class DOMRenderer {
510
847
  const element = this.nodes.get(id);
511
848
  if (!element) return;
512
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
+
513
858
  if (element.parentNode) {
514
859
  element.parentNode.removeChild(element);
515
860
  }
516
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
+
517
870
  // If the root gets detached (unlikely but defensible), clear
518
871
  // rootId so a later attach can re-root cleanly.
519
872
  if (this.rootId === id) {
@@ -521,6 +874,23 @@ export class DOMRenderer {
521
874
  }
522
875
  }
523
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
+
524
894
  /**
525
895
  * Remove an element from the tree
526
896
  */
@@ -534,8 +904,12 @@ export class DOMRenderer {
534
904
  this.canvasRenderers.delete(id);
535
905
  this.canvasElements.delete(id);
536
906
  // Remove all subtree entries pointing to this canvas root
537
- for (const [nodeId, rootId] of this.canvasSubtreeMap) {
538
- 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
+ }
539
913
  }
540
914
  }
541
915
 
@@ -546,7 +920,17 @@ export class DOMRenderer {
546
920
  element.parentNode.removeChild(element);
547
921
  }
548
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
+
549
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);
550
934
 
551
935
  if (this.rootId === id) {
552
936
  this.rootId = null;
@@ -571,6 +955,8 @@ export class DOMRenderer {
571
955
  this.canvasRenderers.clear();
572
956
  this.canvasElements.clear();
573
957
  this.canvasSubtreeMap.clear();
958
+ this.canvasNodeChildren.clear();
959
+ this.canvasNodeParent.clear();
574
960
 
575
961
  // Dispose all element resources before clearing
576
962
  for (const element of this.nodes.values()) {
@@ -578,7 +964,12 @@ export class DOMRenderer {
578
964
  }
579
965
  this.container.innerHTML = "";
580
966
  this.nodes.clear();
967
+ this.textBindings.clear();
581
968
  this.rootId = null;
969
+ this.dialogIds.clear();
970
+ this.dialogOpeners.clear();
971
+ this.pendingDialogMounts = [];
972
+ this.routeFocusMemory.clear();
582
973
  }
583
974
 
584
975
  /**