@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
@@ -4,153 +4,512 @@
4
4
  * Maintain shadow DOM for screen readers
5
5
  */
6
6
 
7
+ import type { Semantics } from "@hypen-space/core/types";
7
8
  import type { VirtualNode } from "./types.js";
8
- import { walkTree } from "./utils.js";
9
9
 
10
10
  /**
11
- * Accessibility Manager
11
+ * Shadow tags that already convey their role natively, so we don't set an
12
+ * explicit `role` on them (it would be redundant). Generic `<div>`/`<span>`
13
+ * hosts are absent, so engine roles like `status`/`progressbar` land there.
14
+ */
15
+ const NATIVE_SHADOW_TAGS = new Set([
16
+ "BUTTON",
17
+ "A",
18
+ "IMG",
19
+ "INPUT",
20
+ "TEXTAREA",
21
+ "SELECT",
22
+ "H1",
23
+ "H2",
24
+ "H3",
25
+ "H4",
26
+ "H5",
27
+ "H6",
28
+ ]);
29
+
30
+ /**
31
+ * Attributes this module set on a shadow element on its previous pass, so a
32
+ * re-apply (a reactive `setSemantics` patch) can clear exactly what it owns —
33
+ * a dropped `expanded` must remove its stale `aria-expanded`, a name going
34
+ * away its `aria-label`.
35
+ */
36
+ const appliedShadowAttrs = new WeakMap<HTMLElement, string[]>();
37
+
38
+ /**
39
+ * Apply engine-derived accessibility semantics to a shadow-tree element.
40
+ *
41
+ * Unlike the DOM renderer, the canvas paints pixels — the accessible name is
42
+ * *not* otherwise exposed — so the derived name is applied directly as
43
+ * `aria-label` (whether derived or explicit). Roles are set only on generic
44
+ * hosts that don't convey them natively. `hidden` is handled by the caller
45
+ * (the node is omitted from the shadow tree entirely).
46
+ *
47
+ * Called at shadow-node create and again on every reactive `setSemantics`
48
+ * re-apply. Idempotent and clearing: attributes set on a previous pass that
49
+ * the new block no longer produces are removed.
50
+ */
51
+ export function applyShadowSemantics(element: HTMLElement, semantics?: Semantics): void {
52
+ if (!semantics && !appliedShadowAttrs.has(element)) return;
53
+
54
+ const next: Array<[string, string]> = [];
55
+
56
+ if (semantics) {
57
+ if (semantics.name) {
58
+ next.push(["aria-label", semantics.name]);
59
+ }
60
+
61
+ const tag = element.tagName?.toUpperCase();
62
+ if (semantics.role && !NATIVE_SHADOW_TAGS.has(tag)) {
63
+ next.push(["role", semantics.role]);
64
+ }
65
+
66
+ if (semantics.description) {
67
+ next.push(["aria-description", semantics.description]);
68
+ }
69
+
70
+ // Self-state relationship attributes.
71
+ if (semantics.expanded !== undefined) {
72
+ next.push(["aria-expanded", String(semantics.expanded)]);
73
+ }
74
+ if (semantics.pressed !== undefined) {
75
+ next.push(["aria-pressed", String(semantics.pressed)]);
76
+ }
77
+ if (semantics.selected !== undefined) {
78
+ next.push(["aria-selected", String(semantics.selected)]);
79
+ }
80
+ if (semantics.current) {
81
+ next.push(["aria-current", semantics.current]);
82
+ }
83
+ if (semantics.checked !== undefined) {
84
+ next.push(["aria-checked", String(semantics.checked)]);
85
+ }
86
+
87
+ if (semantics.busy) {
88
+ next.push(["aria-busy", "true"]);
89
+ }
90
+
91
+ if (semantics.live) {
92
+ // Live-region politeness — engine-validated ("polite" | "assertive").
93
+ next.push(["aria-live", semantics.live]);
94
+ }
95
+ }
96
+
97
+ // Clear what the previous pass set but this one doesn't.
98
+ const previous = appliedShadowAttrs.get(element);
99
+ if (previous) {
100
+ const keep = new Set(next.map(([name]) => name));
101
+ for (const name of previous) {
102
+ if (!keep.has(name)) {
103
+ element.removeAttribute(name);
104
+ }
105
+ }
106
+ }
107
+
108
+ for (const [name, value] of next) {
109
+ element.setAttribute(name, value);
110
+ }
111
+
112
+ if (next.length > 0) {
113
+ appliedShadowAttrs.set(element, next.map(([name]) => name));
114
+ } else {
115
+ appliedShadowAttrs.delete(element);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Accessibility Mirror — positioned transparent semantics overlay
121
+ *
122
+ * A live DOM mirror of the virtual tree, rendered INVISIBLY over the canvas
123
+ * with every element absolutely positioned at its painted bounds (the
124
+ * Flutter-web / Google-Docs approach). Canvas *fallback content* was tried
125
+ * first and rejected on evidence: Chromium exposes fallback elements to the
126
+ * accessibility tree (names, roles, focus all work) but gives them ZERO
127
+ * geometry — and screen-reader browse modes (VoiceOver cursor, rotor, touch
128
+ * exploration) are geometry-driven, so they skip boundless elements. A
129
+ * rendered-but-transparent overlay gets real boxes, which makes browse mode
130
+ * work and puts native focus rings exactly over the painted controls.
131
+ *
132
+ * Real DOM focus on mirror elements is the single source of truth for
133
+ * `node.focused`; the overlay is `pointer-events: none` throughout so the
134
+ * canvas keeps all pointer interaction.
135
+ *
136
+ * The mirror is synced **incrementally** from the same patch stream that
137
+ * drives the canvas (`create/insert/move/remove/detach/attach/...`), so
138
+ * mirror elements keep their identity across re-renders — a screen reader's
139
+ * virtual cursor or a keyboard user's focus survives reactive updates
140
+ * instead of being destroyed by a rebuild. Element positions are refreshed
141
+ * after each canvas render via {@link syncPositions}.
12
142
  */
13
143
  export class AccessibilityLayer {
14
- private shadowRoot: HTMLElement;
144
+ private mirrorRoot: HTMLElement;
15
145
  private nodeMap = new Map<string, HTMLElement>();
16
146
  private enabled: boolean;
147
+ /** Whether the environment can host a mirror at all. */
148
+ private supported: boolean;
149
+ private canvas: HTMLElement | null;
17
150
 
18
- constructor(container: HTMLElement | null, enabled: boolean = true) {
19
- this.enabled = enabled && typeof document !== "undefined";
20
-
21
- // Create shadow root container (only in browser)
22
- if (this.enabled && typeof document !== "undefined") {
23
- this.shadowRoot = document.createElement("div");
24
- this.shadowRoot.setAttribute("role", "application");
25
- this.shadowRoot.setAttribute("aria-label", "Hypen Canvas Application");
26
- this.shadowRoot.style.position = "absolute";
27
- this.shadowRoot.style.left = "-9999px";
28
- this.shadowRoot.style.width = "1px";
29
- this.shadowRoot.style.height = "1px";
30
- this.shadowRoot.style.overflow = "hidden";
31
-
32
- if (container) {
33
- container.appendChild(this.shadowRoot);
151
+ /**
152
+ * Last box written to each mirror element, so the per-frame position sync
153
+ * only touches elements whose geometry actually changed — style writes on
154
+ * n absolutely-positioned elements per frame are what they cost.
155
+ */
156
+ private lastSyncedBounds = new WeakMap<
157
+ HTMLElement,
158
+ { x: number; y: number; width: number; height: number }
159
+ >();
160
+ private lastRootRect: { left: number; top: number; width: number; height: number } | null =
161
+ null;
162
+
163
+ private boundReposition = () => this.repositionRoot();
164
+
165
+ constructor(canvas: HTMLElement | null, enabled: boolean = true) {
166
+ this.canvas = canvas;
167
+ // The overlay mounts on document.body (positioned with page
168
+ // coordinates, like every canvas-adjacent helper here) — non-browser
169
+ // environments disable the mirror rather than crash.
170
+ this.supported =
171
+ typeof document !== "undefined" &&
172
+ !!(document as any).body &&
173
+ typeof (document as any).body.appendChild === "function" &&
174
+ !!canvas;
175
+ this.enabled = enabled && this.supported;
176
+
177
+ if (this.supported) {
178
+ // Plain container — deliberately NO role="application": that role
179
+ // switches screen readers out of virtual-cursor mode (arrow-key
180
+ // browsing stops working) and announces the container on every
181
+ // entry. The mirror is ordinary document content.
182
+ this.mirrorRoot = document.createElement("div");
183
+ this.mirrorRoot.setAttribute("data-hypen-a11y-overlay", "");
184
+ Object.assign(this.mirrorRoot.style, {
185
+ position: "absolute",
186
+ overflow: "hidden",
187
+ // The canvas owns ALL pointer interaction; the overlay is only for
188
+ // keyboard and assistive technology.
189
+ pointerEvents: "none",
190
+ });
191
+ if (this.enabled) {
192
+ this.mount();
34
193
  }
35
194
  } else {
36
- // Fallback for non-browser environments
37
- this.shadowRoot = {} as HTMLElement;
195
+ // Inert placeholder for non-browser environments
196
+ this.mirrorRoot = {} as HTMLElement;
197
+ }
198
+ }
199
+
200
+ private mount(): void {
201
+ // Pseudo-elements can't be styled inline: placeholders (and selection
202
+ // highlights) would otherwise paint through the transparent overlay.
203
+ if (!(document as any).getElementById?.("hypen-a11y-overlay-style")) {
204
+ const style = document.createElement("style");
205
+ style.id = "hypen-a11y-overlay-style";
206
+ style.textContent = [
207
+ "[data-hypen-a11y-overlay] input::placeholder,",
208
+ "[data-hypen-a11y-overlay] textarea::placeholder { color: transparent; opacity: 0; }",
209
+ "[data-hypen-a11y-overlay] ::selection { background: transparent; }",
210
+ "[data-hypen-a11y-overlay] input,",
211
+ "[data-hypen-a11y-overlay] textarea { resize: none; appearance: none; -webkit-appearance: none; }",
212
+ ].join("\n");
213
+ (document as any).head?.appendChild?.(style);
214
+ }
215
+
216
+ (document as any).body.appendChild(this.mirrorRoot);
217
+ // The overlay carries the semantics; the (now childless) canvas would
218
+ // otherwise surface as an anonymous element in browse mode.
219
+ (this.canvas as any)?.setAttribute?.("aria-hidden", "true");
220
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
221
+ window.addEventListener("resize", this.boundReposition);
222
+ window.addEventListener("scroll", this.boundReposition, true);
38
223
  }
224
+ this.repositionRoot();
39
225
  }
40
226
 
227
+ private unmount(): void {
228
+ this.mirrorRoot.remove();
229
+ (this.canvas as any)?.removeAttribute?.("aria-hidden");
230
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
231
+ window.removeEventListener("resize", this.boundReposition);
232
+ window.removeEventListener("scroll", this.boundReposition, true);
233
+ }
234
+ }
235
+
236
+ /** Pin the overlay root to the canvas's page rect. */
237
+ private repositionRoot(): void {
238
+ if (!this.enabled) return;
239
+ const rect = (this.canvas as any)?.getBoundingClientRect?.();
240
+ if (!rect) return;
241
+ const sx = typeof window !== "undefined" ? window.scrollX ?? 0 : 0;
242
+ const sy = typeof window !== "undefined" ? window.scrollY ?? 0 : 0;
243
+ const left = rect.left + sx;
244
+ const top = rect.top + sy;
245
+ const last = this.lastRootRect;
246
+ if (
247
+ last &&
248
+ last.left === left &&
249
+ last.top === top &&
250
+ last.width === rect.width &&
251
+ last.height === rect.height
252
+ ) {
253
+ return;
254
+ }
255
+ this.lastRootRect = { left, top, width: rect.width, height: rect.height };
256
+ Object.assign(this.mirrorRoot.style, {
257
+ left: `${left}px`,
258
+ top: `${top}px`,
259
+ width: `${rect.width}px`,
260
+ height: `${rect.height}px`,
261
+ });
262
+ }
263
+
264
+ // -------------------------------------------------------------------------
265
+ // Incremental patch mirroring
266
+ // -------------------------------------------------------------------------
267
+
41
268
  /**
42
- * Sync shadow DOM with virtual tree
269
+ * Mirror a `create` patch: build the element (detached — a subsequent
270
+ * `insert` places it). Elements mirror the virtual tree 1:1; visibility
271
+ * and decorative hiding are expressed with `display:none` (which removes
272
+ * the subtree from the AT tree) instead of omission, so sibling order
273
+ * always matches the virtual tree and `beforeId` mapping stays exact.
274
+ *
275
+ * The mirror's accessibility metadata (role, name, busy, hidden) comes
276
+ * from the engine-derived {@link Semantics} block — the single source of
277
+ * truth shared with every other renderer — not from ad-hoc prop sniffing.
278
+ * Form *values* (input value/placeholder) still come from props, as those
279
+ * aren't semantics.
43
280
  */
44
- syncTree(root: VirtualNode): void {
281
+ createNode(node: VirtualNode): void {
45
282
  if (!this.enabled) return;
46
283
 
47
- // Clear existing
48
- this.shadowRoot.innerHTML = "";
49
- this.nodeMap.clear();
284
+ let element = this.nodeMap.get(node.id);
285
+ if (!element) {
286
+ element = this.createShadowElement(node);
287
+ element.setAttribute("data-hypen-id", node.id);
288
+ // Rendered but invisible: real boxes for AT geometry, zero visual
289
+ // presence — the canvas paints all pixels. `outline` is deliberately
290
+ // NOT suppressed: the native focus ring lands exactly over the
291
+ // painted control. Positions are written by syncPositions.
292
+ Object.assign(element.style, {
293
+ position: "absolute",
294
+ margin: "0",
295
+ padding: "0",
296
+ border: "0",
297
+ background: "transparent",
298
+ color: "transparent",
299
+ caretColor: "transparent",
300
+ overflow: "hidden",
301
+ });
302
+ this.nodeMap.set(node.id, element);
303
+ }
50
304
 
51
- // Build shadow DOM
52
- const shadowNode = this.createShadowNode(root);
53
- if (shadowNode) {
54
- this.shadowRoot.appendChild(shadowNode);
305
+ if (node.focusable) {
306
+ element.tabIndex = 0;
55
307
  }
308
+
309
+ applyShadowSemantics(element, node.semantics);
310
+ this.syncVisibility(element, node);
56
311
  }
57
312
 
58
313
  /**
59
- * Create shadow DOM element for virtual node
314
+ * Mirror an `insert` or `attach` patch. Re-inserting an element that is
315
+ * already in the tree moves it (native `insertBefore` semantics), so
316
+ * `move` routes here too. An `attach` reinserts the exact element kept
317
+ * alive by {@link detachNode} — AT identity survives router navigation.
60
318
  */
61
- private createShadowNode(node: VirtualNode): HTMLElement | null {
62
- if (!node.visible) return null;
319
+ insertNode(parentId: string, id: string, beforeId?: string): void {
320
+ if (!this.enabled) return;
63
321
 
64
- let element: HTMLElement;
322
+ const element = this.nodeMap.get(id);
323
+ if (!element) return;
65
324
 
66
- // Create appropriate HTML element
67
- switch (node.type.toLowerCase()) {
68
- case "button":
69
- element = document.createElement("button");
70
- element.textContent = this.getNodeText(node);
71
- if (node.props.onclick) {
72
- element.setAttribute("aria-label", "Clickable button");
73
- }
74
- break;
75
-
76
- case "input":
77
- element = document.createElement("input");
78
- (element as HTMLInputElement).type = node.props.type || "text";
79
- (element as HTMLInputElement).value = node.props.value || "";
80
- if (node.props.placeholder) {
81
- (element as HTMLInputElement).placeholder = node.props.placeholder;
82
- }
83
- break;
84
-
85
- case "textarea":
86
- element = document.createElement("textarea");
87
- (element as HTMLTextAreaElement).value = node.props.value || "";
88
- if (node.props.placeholder) {
89
- (element as HTMLTextAreaElement).placeholder = node.props.placeholder;
90
- }
91
- break;
325
+ // Root mount: the engine addresses the root as its own parent (or under
326
+ // the reserved "root" id). Everything else hangs off its parent element.
327
+ const parent =
328
+ parentId === id
329
+ ? this.mirrorRoot
330
+ : this.nodeMap.get(parentId) ?? (parentId === "root" ? this.mirrorRoot : null);
331
+ if (!parent) return;
332
+
333
+ const before = beforeId ? this.nodeMap.get(beforeId) : null;
334
+ if (before && before.parentNode === parent) {
335
+ parent.insertBefore(element, before);
336
+ } else {
337
+ // Unknown/foreign beforeId same append fallback the virtual tree uses.
338
+ parent.appendChild(element);
339
+ }
340
+ }
92
341
 
93
- case "image":
94
- element = document.createElement("img");
95
- (element as HTMLImageElement).src = node.props.src || "";
96
- (element as HTMLImageElement).alt = node.props.alt || "Image";
97
- break;
342
+ /**
343
+ * Mirror a `remove` patch: drop the element and purge the subtree's
344
+ * id mappings.
345
+ */
346
+ removeNode(node: VirtualNode): void {
347
+ if (!this.enabled) return;
348
+ this.nodeMap.get(node.id)?.remove();
349
+ this.purge(node);
350
+ }
98
351
 
99
- case "text":
100
- element = document.createElement("span");
101
- element.textContent = String(node.props[0] || node.props.text || "");
102
- break;
352
+ private purge(node: VirtualNode): void {
353
+ this.nodeMap.delete(node.id);
354
+ for (const child of node.children) {
355
+ this.purge(child);
356
+ }
357
+ }
103
358
 
104
- case "column":
105
- case "row":
106
- case "container":
107
- case "box":
108
- element = document.createElement("div");
109
- element.setAttribute("role", node.type === "column" ? "group" : "group");
110
- break;
359
+ /**
360
+ * Mirror a `detach` patch: unlink the element but keep the subtree and
361
+ * all id mappings alive so a later `attach` reinserts the same elements
362
+ * (router subtree cache parity).
363
+ */
364
+ detachNode(id: string): void {
365
+ if (!this.enabled) return;
366
+ this.nodeMap.get(id)?.remove();
367
+ }
111
368
 
112
- default:
113
- element = document.createElement("div");
114
- }
369
+ /**
370
+ * Full rebuild from a virtual tree. Only used when re-enabling the mirror
371
+ * (`setEnabled(true)`) and by `clear()` — steady-state sync is incremental.
372
+ */
373
+ rebuild(root: VirtualNode | null): void {
374
+ if (!this.enabled) return;
115
375
 
116
- // Set common attributes
117
- element.setAttribute("data-hypen-id", node.id);
376
+ this.mirrorRoot.innerHTML = "";
377
+ this.nodeMap.clear();
118
378
 
119
- if (node.props["aria-label"]) {
120
- element.setAttribute("aria-label", node.props["aria-label"]);
379
+ if (root) {
380
+ this.mountSubtree(root, this.mirrorRoot);
121
381
  }
382
+ }
122
383
 
123
- if (node.focusable) {
124
- element.tabIndex = 0;
384
+ private mountSubtree(node: VirtualNode, parent: HTMLElement): void {
385
+ this.createNode(node);
386
+ const element = this.nodeMap.get(node.id);
387
+ if (!element) return;
388
+ this.updateNode(node);
389
+ parent.appendChild(element);
390
+ for (const child of node.children) {
391
+ this.mountSubtree(child, element);
125
392
  }
393
+ }
126
394
 
127
- // Store mapping
128
- this.nodeMap.set(node.id, element);
395
+ /**
396
+ * `display:none` removes the subtree from the accessibility tree — used
397
+ * both for `visible: false` and for decorative (`semantics.hidden`) nodes.
398
+ */
399
+ private syncVisibility(element: HTMLElement, node: VirtualNode): void {
400
+ element.style.display = node.visible && !node.semantics?.hidden ? "" : "none";
401
+ }
129
402
 
130
- // Add children
131
- for (const child of node.children) {
132
- const childElement = this.createShadowNode(child);
133
- if (childElement) {
134
- element.appendChild(childElement);
135
- }
403
+ /**
404
+ * Write each mirror element's box to its node's painted (scroll-aware)
405
+ * bounds called by the renderer after every canvas render, so AT
406
+ * geometry follows layout changes and container scrolling. Positions are
407
+ * parent-relative (every mirror element is absolutely positioned).
408
+ */
409
+ syncPositions(root: VirtualNode | null): void {
410
+ if (!this.enabled) return;
411
+ this.repositionRoot();
412
+ if (root) {
413
+ this.syncNodePosition(root, 0, 0, 0, 0);
136
414
  }
137
-
138
- return element;
139
415
  }
140
416
 
141
417
  /**
142
- * Get text content from node tree
418
+ * `scrollX`/`scrollY` accumulate the ancestors' scroll offsets down the
419
+ * recursion (the same subtraction `getScrollAwareBounds` derives by
420
+ * walking up), keeping the pass O(n) instead of O(n·depth). Writes are
421
+ * skipped when the element's box is unchanged since the last sync.
143
422
  */
144
- private getNodeText(node: VirtualNode): string {
145
- if (node.type === "text") {
146
- return String(node.props[0] || node.props.text || "");
423
+ private syncNodePosition(
424
+ node: VirtualNode,
425
+ parentX: number,
426
+ parentY: number,
427
+ scrollX: number,
428
+ scrollY: number,
429
+ ): void {
430
+ const element = this.nodeMap.get(node.id);
431
+ if (!element || !node.layout) return;
432
+
433
+ const x = node.layout.x - scrollX;
434
+ const y = node.layout.y - scrollY;
435
+ const width = node.layout.width;
436
+ const height = node.layout.height;
437
+
438
+ const relX = x - parentX;
439
+ const relY = y - parentY;
440
+ const last = this.lastSyncedBounds.get(element);
441
+ if (!last || last.x !== relX || last.y !== relY || last.width !== width || last.height !== height) {
442
+ element.style.left = `${relX}px`;
443
+ element.style.top = `${relY}px`;
444
+ element.style.width = `${width}px`;
445
+ element.style.height = `${height}px`;
446
+ this.lastSyncedBounds.set(element, { x: relX, y: relY, width, height });
147
447
  }
148
448
 
149
- let text = "";
449
+ const childScrollX = scrollX + (node.scrollState?.scrollX ?? 0);
450
+ const childScrollY = scrollY + (node.scrollState?.scrollY ?? 0);
150
451
  for (const child of node.children) {
151
- text += this.getNodeText(child);
452
+ this.syncNodePosition(child, x, y, childScrollX, childScrollY);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Choose the semantic HTML tag for a node's shadow element and set its
458
+ * non-accessibility content (text, input value/placeholder). Roles/names are
459
+ * applied separately from the semantics block.
460
+ */
461
+ private createShadowElement(node: VirtualNode): HTMLElement {
462
+ switch (node.type.toLowerCase()) {
463
+ case "button":
464
+ return document.createElement("button");
465
+
466
+ case "link": {
467
+ const a = document.createElement("a");
468
+ a.setAttribute("href", "#");
469
+ return a;
470
+ }
471
+
472
+ case "input": {
473
+ const input = document.createElement("input") as HTMLInputElement;
474
+ input.type = node.props.type || "text";
475
+ input.value = node.props.value || "";
476
+ if (node.props.placeholder) input.placeholder = node.props.placeholder;
477
+ return input;
478
+ }
479
+
480
+ case "textarea": {
481
+ const textarea = document.createElement("textarea") as HTMLTextAreaElement;
482
+ textarea.value = node.props.value || "";
483
+ if (node.props.placeholder) textarea.placeholder = node.props.placeholder;
484
+ return textarea;
485
+ }
486
+
487
+ case "image":
488
+ return document.createElement("img");
489
+
490
+ case "heading": {
491
+ // Use the derived level when known; fall back to a generic h2.
492
+ const level = Math.min(6, Math.max(1, node.semantics?.level ?? 2));
493
+ return document.createElement(`h${level}`);
494
+ }
495
+
496
+ case "text": {
497
+ const span = document.createElement("span");
498
+ span.textContent = String(node.props[0] || node.props.text || "");
499
+ return span;
500
+ }
501
+
502
+ case "column":
503
+ case "row":
504
+ case "container":
505
+ case "box":
506
+ // Plain div — role="group" made browse mode announce "group" on
507
+ // every layout container (the DOM renderer emits plain divs too).
508
+ return document.createElement("div");
509
+
510
+ default:
511
+ return document.createElement("div");
152
512
  }
153
- return text;
154
513
  }
155
514
 
156
515
  /**
@@ -174,18 +533,33 @@ export class AccessibilityLayer {
174
533
  const element = this.nodeMap.get(node.id);
175
534
  if (!element) return;
176
535
 
536
+ const type = node.type.toLowerCase();
537
+
177
538
  // Update text content
178
- if (node.type === "text") {
539
+ if (type === "text") {
179
540
  element.textContent = String(node.props[0] || node.props.text || "");
180
541
  }
181
542
 
182
- // Update input value
183
- if (node.type === "input" || node.type === "textarea") {
184
- (element as HTMLInputElement | HTMLTextAreaElement).value = node.props.value || "";
543
+ // Update input value. Guarded — assigning even an identical value can
544
+ // collapse the browser's caret/selection in a focused mirror input.
545
+ if (type === "input" || type === "textarea") {
546
+ const input = element as HTMLInputElement | HTMLTextAreaElement;
547
+ const value = node.props.value || "";
548
+ if (input.value !== value) {
549
+ input.value = value;
550
+ }
551
+ if (node.props.placeholder != null) {
552
+ input.placeholder = node.props.placeholder;
553
+ }
185
554
  }
186
555
 
187
- // Update visibility
188
- element.style.display = node.visible ? "" : "none";
556
+ // Re-apply semantics: painted text is invisible to AT, so a reactively
557
+ // changed accessible name/state only reaches screen readers through the
558
+ // shadow node's ARIA attributes. `applyShadowSemantics` clears anything
559
+ // it set previously that the current block no longer produces.
560
+ applyShadowSemantics(element, node.semantics);
561
+
562
+ this.syncVisibility(element, node);
189
563
  }
190
564
 
191
565
  /**
@@ -195,13 +569,31 @@ export class AccessibilityLayer {
195
569
  return this.nodeMap.get(nodeId);
196
570
  }
197
571
 
572
+ /** The fallback-content root element (for focus delegation wiring). */
573
+ getRoot(): HTMLElement | null {
574
+ return this.supported ? this.mirrorRoot : null;
575
+ }
576
+
577
+ isEnabled(): boolean {
578
+ return this.enabled;
579
+ }
580
+
198
581
  /**
199
- * Enable or disable accessibility layer
582
+ * Enable or disable the mirror. Re-enabling rebuilds from the given
583
+ * virtual tree (incremental sync has no history to replay).
200
584
  */
201
- setEnabled(enabled: boolean): void {
202
- this.enabled = enabled;
203
- if (!enabled && this.shadowRoot.parentElement) {
204
- this.shadowRoot.remove();
585
+ setEnabled(enabled: boolean, root?: VirtualNode | null): void {
586
+ const next = enabled && this.supported;
587
+ if (next === this.enabled) return;
588
+ this.enabled = next;
589
+
590
+ if (!next) {
591
+ this.unmount();
592
+ this.nodeMap.clear();
593
+ } else {
594
+ this.mount();
595
+ this.rebuild(root ?? null);
596
+ this.syncPositions(root ?? null);
205
597
  }
206
598
  }
207
599
 
@@ -209,8 +601,8 @@ export class AccessibilityLayer {
209
601
  * Cleanup
210
602
  */
211
603
  destroy(): void {
212
- if (this.shadowRoot.parentElement) {
213
- this.shadowRoot.remove();
604
+ if (this.supported) {
605
+ this.unmount();
214
606
  }
215
607
  this.nodeMap.clear();
216
608
  }