@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
@@ -21,10 +21,13 @@ import type {
21
21
  LayoutFunction,
22
22
  } from "./types.js";
23
23
  import { computeLayout, initTaffyLayout } from "./layout.js";
24
- import { paintNode, registerPainter } from "./paint.js";
24
+ import { clearTextCache } from "./text.js";
25
+ import { paintNode, registerPainter, clearCharAdvanceCache } from "./paint.js";
25
26
  import { CanvasEventManager } from "./events.js";
26
- import { InputOverlay } from "./input.js";
27
27
  import { AccessibilityLayer } from "./accessibility.js";
28
+ import { FocusManager } from "./focus.js";
29
+ import { TextEditController, isEditableNode } from "./editing.js";
30
+ import { setTextEditor } from "./paint.js";
28
31
  import { findNodeById } from "./utils.js";
29
32
  import { ScrollManager } from "./scroll.js";
30
33
  import { SelectionManager } from "./selection.js";
@@ -35,13 +38,13 @@ import {
35
38
  refreshApplicator,
36
39
  parseApplicatorBase,
37
40
  } from "./props.js";
41
+ import { applyVariants, invalidateVariantCache, deriveNodeComputed } from "./variants.js";
38
42
 
39
43
  const DEFAULT_OPTIONS: CanvasRendererOptions = {
40
44
  devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
41
45
  backgroundColor: "#ffffff",
42
46
  enableAccessibility: true,
43
47
  enableHitTesting: true,
44
- enableInputOverlay: true,
45
48
  enableDirtyRects: false,
46
49
  enableLayerCaching: false,
47
50
  maxLayerCacheSize: 10,
@@ -65,21 +68,34 @@ export class CanvasRenderer implements Renderer {
65
68
  private eventManager: CanvasEventManager;
66
69
  private scrollManager: ScrollManager;
67
70
  private selectionManager: SelectionManager;
68
- private inputOverlay: InputOverlay;
69
71
  private accessibilityLayer: AccessibilityLayer;
72
+ private focusManager: FocusManager;
73
+ private textEditor: TextEditController;
70
74
 
71
75
  private dirtyTracker: DirtyRectTracker;
72
76
 
73
77
  private rafId: number | null = null;
74
78
  private needsRedraw = false;
75
79
 
76
- private resizeObserver: ResizeObserver | null = null;
77
- private dprMediaQuery: MediaQueryList | null = null;
78
- private dprChangeHandler: (() => void) | null = null;
80
+ // Whether the next frame must re-run layout. Patches, resize, font loads,
81
+ // and image decodes set it; pure paint frames (scroll, hover, caret blink,
82
+ // scrollbar fade) leave it clear so the frame skips the Taffy solve.
83
+ private layoutDirty = true;
79
84
 
80
85
  private frameCount = 0;
81
86
  private lastFrameTime = 0;
82
87
 
88
+ private boundFontsLoaded = () => {
89
+ // Metrics measured against the fallback font are stale now — both the
90
+ // wrapped-text metrics and the per-glyph advances used by the manual
91
+ // letter-spacing path (keyed by CSS font string, which does not change
92
+ // when the real font replaces the fallback).
93
+ clearTextCache();
94
+ clearCharAdvanceCache();
95
+ this.layoutDirty = true;
96
+ this.scheduleRedraw();
97
+ };
98
+
83
99
  constructor(canvas: HTMLCanvasElement, engine: IEngine, options?: Partial<CanvasRendererOptions>) {
84
100
  this.canvas = canvas;
85
101
  this.engine = engine;
@@ -105,66 +121,84 @@ export class CanvasRenderer implements Renderer {
105
121
  this.scrollManager = new ScrollManager(canvas, () => this.scheduleRedraw());
106
122
  this.selectionManager = new SelectionManager(canvas, () => this.scheduleRedraw());
107
123
  setSelectionManager(this.selectionManager);
108
- this.inputOverlay = new InputOverlay(
109
- (canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null)
110
- );
124
+ // The mirror is a transparent positioned overlay above the canvas: its
125
+ // elements are exposed to AT with REAL geometry (screen-reader browse
126
+ // modes are geometry-driven) and join the native tab order, while the
127
+ // canvas paints all pixels and keeps all pointer events.
111
128
  this.accessibilityLayer = new AccessibilityLayer(
112
- (canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null),
129
+ canvas as unknown as HTMLElement,
113
130
  this.options.enableAccessibility || false
114
131
  );
115
132
 
116
- // Bridge focus changes from the hit-tester to the HTML input overlay.
117
- // Without this, `Input`/`Textarea` painted on the canvas but clicking
118
- // one did nothing the overlay never got mounted.
119
- this.eventManager.setFocusChangeHandler((next) => {
120
- const t = next ? next.type.toLowerCase() : null;
121
- if (next && (t === "input" || t === "textarea")) {
122
- const rect = this.canvas.getBoundingClientRect();
123
- this.inputOverlay.showInput(
124
- next,
125
- rect,
126
- (value) => {
127
- // Local mirror so the next paint sees the typed text even
128
- // before the engine echoes it back via SetProp.
129
- next.props.value = value;
130
- this.scheduleRedraw();
131
- },
132
- this.engine,
133
- );
134
- } else {
135
- this.inputOverlay.hideInput();
136
- }
133
+ // Text editing happens IN the focused mirror element (the browser owns
134
+ // value/caret/selection/IME there); the controller reads that state and
135
+ // the canvas paints text, selection, and caret natively.
136
+ this.textEditor = new TextEditController(canvas, engine, {
137
+ scheduleRedraw: () => this.scheduleRedraw(),
138
+ markDirty: (node) => {
139
+ if (this.options.enableDirtyRects) {
140
+ this.dirtyTracker.markNodeDirty(node);
141
+ }
142
+ },
143
+ // Proxy blurred to somewhere outside the canvas: node.focused follows.
144
+ onEditBlur: () => this.focusManager.clearFocus(),
145
+ });
146
+ setTextEditor(this.textEditor);
147
+
148
+ // DOM focus on mirror elements (canvas fallback content) is the single
149
+ // source of truth for focus: Tab/Shift+Tab traverse the mirror natively,
150
+ // and the pointer path (hit-test in CanvasEventManager) funnels into the
151
+ // same place by focusing the node's mirror element.
152
+ this.focusManager = new FocusManager(this.accessibilityLayer, engine, {
153
+ getNode: (id) => this.nodes.get(id),
154
+ isAuxFocusTarget: (el) => this.textEditor.isProxyElement(el),
155
+ onFocusChange: (next) => {
156
+ if (next && isEditableNode(next)) {
157
+ const el = this.accessibilityLayer.getElement(next.id);
158
+ if (el) {
159
+ this.textEditor.beginEditing(
160
+ next,
161
+ el as HTMLInputElement | HTMLTextAreaElement,
162
+ );
163
+ }
164
+ } else {
165
+ this.textEditor.endEditing();
166
+ }
167
+ // Repaint so focus styling (input border, button ring) updates.
168
+ this.scheduleRedraw();
169
+ },
170
+ });
171
+ this.eventManager.setFocusManager(this.focusManager);
172
+ this.eventManager.setEditablePointerHandler((node, point) => {
173
+ this.textEditor.placeCaretFromPoint(node, point);
137
174
  });
138
175
 
139
- // Listen for redraw requests from event manager
140
- this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
141
-
142
- // Eagerly initialise Taffy WASM for layout (non-blocking fallback used until ready)
143
- initTaffyLayout();
176
+ // Listen for redraw requests from event manager (paint-only) and image
177
+ // loads (which carry `detail.layout` — a decoded intrinsic size can
178
+ // change the layout, see `getImageNaturalAspect`).
179
+ this.canvas.addEventListener("hypen:redraw", (e: Event) => {
180
+ if ((e as CustomEvent).detail?.layout) {
181
+ this.layoutDirty = true;
182
+ }
183
+ this.scheduleRedraw();
184
+ });
144
185
 
145
- // Observe size changes without this, the canvas backing store stays
146
- // pinned at construction-time dimensions while CSS stretches the
147
- // display box, and content gets bitmap-stretched / hit-testing drifts.
148
- if (typeof ResizeObserver !== "undefined") {
149
- this.resizeObserver = new ResizeObserver(() => {
150
- this.setupHiDPI();
151
- this.scheduleRedraw();
152
- });
153
- this.resizeObserver.observe(this.canvas);
186
+ // A late-loading web font changes every measurement made against the
187
+ // fallback font — re-run layout once the real metrics are available.
188
+ if (typeof document !== "undefined") {
189
+ (document as any).fonts?.addEventListener?.("loadingdone", this.boundFontsLoaded);
154
190
  }
155
191
 
156
- // Track DPR changes (e.g. dragging window across monitors with
157
- // different scaling). matchMedia fires once per crossing.
158
- if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
159
- const dpr = window.devicePixelRatio || 1;
160
- this.dprMediaQuery = window.matchMedia(`(resolution: ${dpr}dppx)`);
161
- this.dprChangeHandler = () => {
162
- this.options.devicePixelRatio = window.devicePixelRatio || 1;
163
- this.setupHiDPI();
192
+ // Eagerly initialise Taffy WASM for layout (non-blocking fallback used
193
+ // until ready). Repaint once it arrives: the fallback's first frame is
194
+ // approximate, and without this redraw its layout would stick until the
195
+ // next state change.
196
+ initTaffyLayout().then((ready) => {
197
+ if (ready) {
198
+ this.layoutDirty = true;
164
199
  this.scheduleRedraw();
165
- };
166
- this.dprMediaQuery.addEventListener("change", this.dprChangeHandler);
167
- }
200
+ }
201
+ });
168
202
 
169
203
  // Don't schedule initial render - wait for patches
170
204
  }
@@ -176,18 +210,15 @@ export class CanvasRenderer implements Renderer {
176
210
  const dpr = this.options.devicePixelRatio || 1;
177
211
  const rect = this.canvas.getBoundingClientRect();
178
212
 
179
- // Skip when the host is collapsed — a 0-pixel backing store throws
180
- // in some 2D contexts and the next non-zero resize will reinitialise.
181
- if (rect.width === 0 || rect.height === 0) return;
182
-
183
213
  this.canvas.width = rect.width * dpr;
184
214
  this.canvas.height = rect.height * dpr;
185
215
 
186
- // Reset transform first — `scale()` is cumulative across calls, so on
187
- // a resize the second call would compound (dpr²) and shrink content.
188
- this.ctx.setTransform(1, 0, 0, 1, 0, 0);
189
216
  this.ctx.scale(dpr, dpr);
190
217
 
218
+ // Update canvas display size
219
+ this.canvas.style.width = `${rect.width}px`;
220
+ this.canvas.style.height = `${rect.height}px`;
221
+
191
222
  // Sync dirty tracker with new canvas size
192
223
  if (this.dirtyTracker) {
193
224
  this.dirtyTracker.setCanvasSize(rect.width, rect.height);
@@ -203,6 +234,7 @@ export class CanvasRenderer implements Renderer {
203
234
  this.canvas.width = width;
204
235
  this.canvas.height = height;
205
236
  this.setupHiDPI();
237
+ this.layoutDirty = true;
206
238
  this.scheduleRedraw();
207
239
  }
208
240
 
@@ -221,10 +253,12 @@ export class CanvasRenderer implements Renderer {
221
253
  this.dirtyTracker.markFullDirty();
222
254
  }
223
255
 
224
- // Update accessibility layer
225
- if (this.rootNode) {
226
- this.accessibilityLayer.syncTree(this.rootNode);
227
- }
256
+ // The accessibility mirror is synced incrementally by the per-patch
257
+ // handlers above — no per-batch rebuild, so mirror element identity
258
+ // (and with it AT focus/virtual-cursor position) survives updates.
259
+
260
+ // Any patch can affect layout (props, tree shape, text).
261
+ this.layoutDirty = true;
228
262
 
229
263
  // Schedule redraw
230
264
  this.scheduleRedraw();
@@ -236,7 +270,7 @@ export class CanvasRenderer implements Renderer {
236
270
  private applyPatch(patch: Patch): void {
237
271
  switch (patch.type) {
238
272
  case "create":
239
- this.onCreate(patch.id!, patch.elementType!, patch.props || {});
273
+ this.onCreate(patch.id!, patch.elementType!, patch.props || {}, patch.semantics);
240
274
  break;
241
275
 
242
276
  case "setProp":
@@ -270,13 +304,38 @@ export class CanvasRenderer implements Renderer {
270
304
  case "attach":
271
305
  this.onInsert(patch.parentId!, patch.id!, patch.beforeId);
272
306
  break;
307
+
308
+ case "setSemantics":
309
+ this.onSetSemantics(patch.id!, patch.semantics);
310
+ break;
273
311
  }
274
312
  }
275
313
 
314
+ /**
315
+ * Reactive accessibility update: swap in the node's complete re-resolved
316
+ * semantics block and re-apply it to the shadow element. This is the only
317
+ * channel that keeps the canvas shadow tree's accessible name/state live —
318
+ * painted text is invisible to AT, so prop deltas alone can't do it.
319
+ */
320
+ private onSetSemantics(
321
+ id: string,
322
+ semantics?: import("@hypen-space/core/types").Semantics,
323
+ ): void {
324
+ const node = this.nodes.get(id);
325
+ if (!node) return;
326
+ node.semantics = semantics;
327
+ this.accessibilityLayer.updateNode(node);
328
+ }
329
+
276
330
  /**
277
331
  * Create new virtual node
278
332
  */
279
- private onCreate(id: string, elementType: string, props: Record<string, any>): void {
333
+ private onCreate(
334
+ id: string,
335
+ elementType: string,
336
+ props: Record<string, any>,
337
+ semantics?: import("@hypen-space/core/types").Semantics,
338
+ ): void {
280
339
  // Engine may send a Map (from WASM) or a plain object. Copy either way so
281
340
  // we own the prop bag and can mutate it during applicator normalisation.
282
341
  const rawProps: Record<string, any> =
@@ -293,6 +352,7 @@ export class CanvasRenderer implements Renderer {
293
352
  id,
294
353
  type: elementType,
295
354
  props: rawProps,
355
+ semantics,
296
356
  children: [],
297
357
  parent: null,
298
358
  visible: true,
@@ -309,6 +369,9 @@ export class CanvasRenderer implements Renderer {
309
369
  };
310
370
 
311
371
  this.nodes.set(id, node);
372
+
373
+ // Mirror the create (element stays detached until its insert patch).
374
+ this.accessibilityLayer.createNode(node);
312
375
  }
313
376
 
314
377
  /**
@@ -325,6 +388,10 @@ export class CanvasRenderer implements Renderer {
325
388
 
326
389
  node.props[name] = value;
327
390
 
391
+ // A new key may introduce a variant marker (e.g. `padding@md.0`) — drop the
392
+ // cached variant-base set so the next frame rescans.
393
+ invalidateVariantCache(node);
394
+
328
395
  // If this is an applicator-namespaced key (e.g. `flex.0`, `onClick.to`),
329
396
  // rebuild the derived flat/aggregate entry under the base name so layout,
330
397
  // paint, and event dispatch see the updated value.
@@ -358,6 +425,12 @@ export class CanvasRenderer implements Renderer {
358
425
 
359
426
  // Update accessibility
360
427
  this.accessibilityLayer.updateNode(node);
428
+
429
+ // Engine value echo for an actively edited input: same value → no-op
430
+ // (the caret must not move); a rewritten value re-seeds the element.
431
+ if (name === "value") {
432
+ this.textEditor.onEngineValueEcho(node);
433
+ }
361
434
  }
362
435
 
363
436
  /**
@@ -373,6 +446,8 @@ export class CanvasRenderer implements Renderer {
373
446
 
374
447
  delete node.props[name];
375
448
 
449
+ invalidateVariantCache(node);
450
+
376
451
  const base = parseApplicatorBase(name);
377
452
  if (base !== null) {
378
453
  refreshApplicator(node.props, base);
@@ -429,6 +504,10 @@ export class CanvasRenderer implements Renderer {
429
504
  const child = this.nodes.get(id);
430
505
  if (!child) return;
431
506
 
507
+ // Mirror the insert/attach/move — insertNode resolves root addressing
508
+ // and unknown-beforeId fallback with the same rules as the code below.
509
+ this.accessibilityLayer.insertNode(parentId, id, beforeId);
510
+
432
511
  // Check if this is setting the root node (parent_id === id === "root" or similar)
433
512
  if (parentId === "root" && id === "root") {
434
513
  this.rootNode = child;
@@ -512,6 +591,15 @@ export class CanvasRenderer implements Renderer {
512
591
  const node = this.nodes.get(id);
513
592
  if (!node) return;
514
593
 
594
+ // An edit session inside the detached subtree ends (flushing any
595
+ // pending composition value) before the subtree leaves the tree, and
596
+ // focus state follows.
597
+ this.textEditor.endIfWithin(node);
598
+ this.focusManager.clearIfWithin(node);
599
+
600
+ // Mirror keeps the element (and subtree ids) alive for re-attach.
601
+ this.accessibilityLayer.detachNode(id);
602
+
515
603
  // Mark dirty so the next paint doesn't leave stale pixels behind.
516
604
  if (this.options.enableDirtyRects) {
517
605
  this.dirtyTracker.markNodeDirty(node);
@@ -543,6 +631,13 @@ export class CanvasRenderer implements Renderer {
543
631
  const node = this.nodes.get(id);
544
632
  if (!node) return;
545
633
 
634
+ // End any edit session inside the removed subtree; focus state follows.
635
+ this.textEditor.endIfWithin(node);
636
+ this.focusManager.clearIfWithin(node);
637
+
638
+ // Drop the mirror element and its subtree's id mappings.
639
+ this.accessibilityLayer.removeNode(node);
640
+
546
641
  // Mark dirty before removal
547
642
  if (this.options.enableDirtyRects) {
548
643
  this.dirtyTracker.markNodeDirty(node);
@@ -599,6 +694,16 @@ export class CanvasRenderer implements Renderer {
599
694
  this.renderFull(dpr);
600
695
  }
601
696
 
697
+ // Layout may have moved the edited input — keep the IME proxy (and with
698
+ // it the IME candidate window) pinned to the painted caret.
699
+ if (this.textEditor.isActive()) {
700
+ this.textEditor.syncProxyPosition();
701
+ }
702
+
703
+ // Keep the semantics overlay's element boxes on the painted bounds so
704
+ // screen-reader browse modes (geometry-driven) track layout changes.
705
+ this.accessibilityLayer.syncPositions(this.rootNode);
706
+
602
707
  // Performance logging
603
708
  if (this.options.logPerformance) {
604
709
  const elapsed = performance.now() - startTime;
@@ -611,6 +716,51 @@ export class CanvasRenderer implements Renderer {
611
716
  }
612
717
  }
613
718
 
719
+ /**
720
+ * Re-derive cached computed fields that may have been overridden by variant
721
+ * resolution. `node.opacity` is read straight off `node.props.opacity` in
722
+ * paint, but it is cached on the node at create/setProp time — after a
723
+ * variant pass changes `props.opacity` (e.g. `opacity:disabled`), the cache
724
+ * must be refreshed or the paint would use the stale value.
725
+ */
726
+ private refreshComputedProps(node: VirtualNode): void {
727
+ deriveNodeComputed(node);
728
+ for (const child of node.children) {
729
+ this.refreshComputedProps(child);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Resolve variants and re-run layout when (and only when) something that
735
+ * affects layout changed since the last frame. Variants are resolved every
736
+ * frame — hover/focus/pressed winners must reach paint — but the Taffy
737
+ * solve, computed-prop refresh, and scroll-bounds pass only run when a
738
+ * patch/resize/font/image marked the layout dirty or a variant winner
739
+ * actually changed (variants can rewrite spacing/size props).
740
+ */
741
+ private runLayoutIfNeeded(dpr: number): void {
742
+ if (!this.rootNode) return;
743
+
744
+ // Resolve responsive + state variants against the current content width
745
+ // BEFORE layout so spacing/size winners feed the layout engine and
746
+ // colour/opacity winners feed paint.
747
+ const contentWidth = this.canvas.width / dpr;
748
+ const variantsChanged = applyVariants(this.rootNode, contentWidth);
749
+ if (!variantsChanged && !this.layoutDirty) return;
750
+
751
+ this.refreshComputedProps(this.rootNode);
752
+
753
+ computeLayout(
754
+ this.ctx,
755
+ this.rootNode,
756
+ contentWidth,
757
+ this.canvas.height / dpr
758
+ );
759
+
760
+ ScrollManager.updateScrollBounds(this.rootNode);
761
+ this.layoutDirty = false;
762
+ }
763
+
614
764
  /**
615
765
  * Full canvas repaint (default behavior when dirty rects disabled)
616
766
  */
@@ -626,14 +776,7 @@ export class CanvasRenderer implements Renderer {
626
776
 
627
777
  // Layout and paint
628
778
  if (this.rootNode) {
629
- computeLayout(
630
- this.ctx,
631
- this.rootNode,
632
- this.canvas.width / dpr,
633
- this.canvas.height / dpr
634
- );
635
-
636
- ScrollManager.updateScrollBounds(this.rootNode);
779
+ this.runLayoutIfNeeded(dpr);
637
780
 
638
781
  paintNode(this.ctx, this.rootNode);
639
782
 
@@ -647,16 +790,7 @@ export class CanvasRenderer implements Renderer {
647
790
  * Optimized render that only repaints dirty regions
648
791
  */
649
792
  private renderWithDirtyRects(dpr: number): void {
650
- // Always run layout so nodes have up-to-date bounds
651
- if (this.rootNode) {
652
- computeLayout(
653
- this.ctx,
654
- this.rootNode,
655
- this.canvas.width / dpr,
656
- this.canvas.height / dpr
657
- );
658
- ScrollManager.updateScrollBounds(this.rootNode);
659
- }
793
+ this.runLayoutIfNeeded(dpr);
660
794
 
661
795
  const dirtyRegion = this.dirtyTracker.getDirtyRegion();
662
796
  this.dirtyTracker.clear();
@@ -682,9 +816,11 @@ export class CanvasRenderer implements Renderer {
682
816
  this.ctx.fillRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
683
817
  }
684
818
 
685
- // Repaint the full tree — clip path ensures only dirty pixels are touched
819
+ // Repaint the tree — the clip path bounds rasterized pixels and the
820
+ // dirty region is threaded through paintNode so subtrees that cannot
821
+ // reach it are pruned from the traversal entirely.
686
822
  if (this.rootNode) {
687
- paintNode(this.ctx, this.rootNode);
823
+ paintNode(this.ctx, this.rootNode, dirtyRegion);
688
824
 
689
825
  if (this.options.showLayoutBounds) {
690
826
  this.drawLayoutBounds(this.rootNode);
@@ -733,11 +869,13 @@ export class CanvasRenderer implements Renderer {
733
869
  * Clear renderer
734
870
  */
735
871
  clear(): void {
872
+ this.textEditor.endEditing();
736
873
  this.rootNode = null;
737
874
  this.nodes.clear();
738
875
  this.eventManager.setRootNode(null);
739
876
  this.scrollManager.setRootNode(null);
740
877
  this.selectionManager.setRootNode(null);
878
+ this.accessibilityLayer.rebuild(null);
741
879
  this.dirtyTracker.clear();
742
880
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
743
881
  }
@@ -754,7 +892,12 @@ export class CanvasRenderer implements Renderer {
754
892
  */
755
893
  setOptions(options: Partial<CanvasRendererOptions>): void {
756
894
  this.options = { ...this.options, ...options };
757
- this.accessibilityLayer.setEnabled(this.options.enableAccessibility || false);
895
+ // Re-enabling rebuilds the mirror from the current tree — incremental
896
+ // sync has no history to replay for the disabled period.
897
+ this.accessibilityLayer.setEnabled(
898
+ this.options.enableAccessibility || false,
899
+ this.rootNode,
900
+ );
758
901
  }
759
902
 
760
903
  /**
@@ -764,19 +907,16 @@ export class CanvasRenderer implements Renderer {
764
907
  if (this.rafId !== null) {
765
908
  cancelAnimationFrame(this.rafId);
766
909
  }
767
- if (this.resizeObserver) {
768
- this.resizeObserver.disconnect();
769
- this.resizeObserver = null;
770
- }
771
- if (this.dprMediaQuery && this.dprChangeHandler) {
772
- this.dprMediaQuery.removeEventListener("change", this.dprChangeHandler);
773
- this.dprMediaQuery = null;
774
- this.dprChangeHandler = null;
910
+ if (typeof document !== "undefined") {
911
+ (document as any).fonts?.removeEventListener?.("loadingdone", this.boundFontsLoaded);
775
912
  }
776
913
  this.eventManager.destroy();
777
914
  this.scrollManager.destroy();
778
915
  this.selectionManager.destroy();
779
916
  setSelectionManager(null);
917
+ this.textEditor.destroy();
918
+ setTextEditor(null);
919
+ this.focusManager.destroy();
780
920
  this.accessibilityLayer.destroy();
781
921
  }
782
922
  }