@effindomv2/runtime 0.1.0

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 (92) hide show
  1. package/LICENSE.md +6 -0
  2. package/dist/bridge.js +4 -0
  3. package/dist/bridge.js.map +7 -0
  4. package/dist/effindom.v2.manifest.json +68 -0
  5. package/dist/fonts/NotoColorEmoji.ttf +0 -0
  6. package/dist/fonts/NotoEmoji-Regular.ttf +0 -0
  7. package/dist/fonts/NotoSans-Bold.ttf +0 -0
  8. package/dist/fonts/NotoSans-BoldItalic.ttf +0 -0
  9. package/dist/fonts/NotoSans-Italic.ttf +0 -0
  10. package/dist/fonts/NotoSans-Regular.ttf +0 -0
  11. package/dist/fonts/NotoSansMono-Bold.ttf +0 -0
  12. package/dist/fonts/NotoSansMono-Regular.ttf +0 -0
  13. package/dist/fonts/NotoSansSymbols2-Regular.ttf +0 -0
  14. package/dist/harness.js +2 -0
  15. package/dist/harness.js.map +7 -0
  16. package/dist/index.html +53 -0
  17. package/dist/runtime/effindom-core-v2.wasm32-simd.JQXIaRaN0-JahfIVFiSLE49WzzCENvef_2EDEm09nJs.wasm +0 -0
  18. package/dist/runtime/effindom-core-v2.wasm32-simd.y7RzpkMARiFeRkpgiqKQsAfv4Hf17NYdpni-6aLNhMs.js.symbols +10079 -0
  19. package/dist/runtime/effindom-core-v2.wasm32-simd.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
  20. package/dist/runtime/effindom-core-v2.wasm32.JSfMkp9ertJzSZxA-_xz3yacrJUhswxlwbqbJLRIuqw.wasm +0 -0
  21. package/dist/runtime/effindom-core-v2.wasm32.xNgsQv7dCwf8Uy-PfJSoRNyk9-q1OSogUwkk5g6ZBjk.js.symbols +10088 -0
  22. package/dist/runtime/effindom-core-v2.wasm32.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
  23. package/dist/runtime/effindom-core-v2.wasm64-simd.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
  24. package/dist/runtime/effindom-core-v2.wasm64-simd.p4P98oRu2wEWxtRRW8RHr27JhGeWvWlziZXDM_z3Nc4.js.symbols +10286 -0
  25. package/dist/runtime/effindom-core-v2.wasm64-simd.y75FYXRwhQrpaDGYbZWrohGDv0AmjTb-EjXwOjBIgnM.wasm +0 -0
  26. package/dist/runtime/effindom-core-v2.wasm64.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
  27. package/dist/runtime/effindom-core-v2.wasm64.emhE1_CJs4_zXp8wiQS_5lYpUQ0OchmXgxksi0ykaBs.js.symbols +10298 -0
  28. package/dist/runtime/effindom-core-v2.wasm64.sO-Yu70cfN8Qs3a5iEp6cbFPaiOchqcMKUzryu4npNo.wasm +0 -0
  29. package/dist/runtime/effindom-ui-v2.wasm32-simd.0Mas1XD03eYvemryTioWaZOBuBA5ij7MFlTa8CgEZWs.wasm +0 -0
  30. package/dist/runtime/effindom-ui-v2.wasm32-simd.ThSDClMnSWdwf9d89JZfYor0G1Z6OxR4lOc75rNRuD4.js.symbols +1890 -0
  31. package/dist/runtime/effindom-ui-v2.wasm32-simd.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
  32. package/dist/runtime/effindom-ui-v2.wasm32.H7kYg99bT9ADGh0uUvj6H9Dk1L058nVFLv_4R79IXW8.js.symbols +1900 -0
  33. package/dist/runtime/effindom-ui-v2.wasm32.tp53X7nHfG_EUq29naDyElfnqhMw2D1Tr1T-BJAYO7w.wasm +0 -0
  34. package/dist/runtime/effindom-ui-v2.wasm32.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
  35. package/dist/runtime/effindom-ui-v2.wasm64-simd.86tk9Z3xIpgTOykET_8Nn9iUVJnp1AzOHW4fVQRGtQE.wasm +0 -0
  36. package/dist/runtime/effindom-ui-v2.wasm64-simd.RQaXil22Chu63-vxK9oOuX8wUY044kbo190oYIbBU4M.js.symbols +1918 -0
  37. package/dist/runtime/effindom-ui-v2.wasm64-simd.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
  38. package/dist/runtime/effindom-ui-v2.wasm64.YSwpMFbr-Q1SBe0Ze8mub1u1PqsvSz3QIYuA3eaUMME.js.symbols +1924 -0
  39. package/dist/runtime/effindom-ui-v2.wasm64.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
  40. package/dist/runtime/effindom-ui-v2.wasm64.ioQ9DuM6gR_EjlfRHdF8EvNPBcKCs0PQbbY9-cjTV6Y.wasm +0 -0
  41. package/dist/runtime/icudt_minimal.962CX1q0-Nbv-OqXPaub5piYTOLumUk-nEvemcvvnpw.dat +0 -0
  42. package/package.json +62 -0
  43. package/scripts/build.sh +279 -0
  44. package/scripts/build_assets.sh +51 -0
  45. package/scripts/font_assets.sh +52 -0
  46. package/scripts/generate_manifest.py +121 -0
  47. package/scripts/stage_package_assets.sh +42 -0
  48. package/src/bridge/commit-policy.ts +10 -0
  49. package/src/bridge/events/canvas-geometry.ts +78 -0
  50. package/src/bridge/events/key-router.ts +187 -0
  51. package/src/bridge/events/pointer-router.ts +619 -0
  52. package/src/bridge/events/semantic-hit-testing.ts +27 -0
  53. package/src/bridge/events.ts +54 -0
  54. package/src/bridge/find-dialog.ts +690 -0
  55. package/src/bridge/find-session.ts +158 -0
  56. package/src/bridge/font-catalog.ts +51 -0
  57. package/src/bridge/google-fonts.ts +63 -0
  58. package/src/bridge/incremental-font-packages.ts +216 -0
  59. package/src/bridge/init.ts +77 -0
  60. package/src/bridge/interaction/editor-model.ts +371 -0
  61. package/src/bridge/interaction/editor-mutations.ts +495 -0
  62. package/src/bridge/interaction/editor-session.ts +628 -0
  63. package/src/bridge/interaction/logs.ts +23 -0
  64. package/src/bridge/interaction/text-encoding.ts +51 -0
  65. package/src/bridge/interaction.ts +86 -0
  66. package/src/bridge/local-types.ts +105 -0
  67. package/src/bridge/platform.ts +68 -0
  68. package/src/bridge/pointer-move-coalescer.ts +41 -0
  69. package/src/bridge/pull-to-refresh.ts +124 -0
  70. package/src/bridge/render-loop.ts +268 -0
  71. package/src/bridge/runtime/asset-manager.ts +202 -0
  72. package/src/bridge/runtime/find-controller.ts +269 -0
  73. package/src/bridge/runtime/font-manager.ts +691 -0
  74. package/src/bridge/runtime/open-canvas-api.ts +72 -0
  75. package/src/bridge/runtime/semantic-controller.ts +133 -0
  76. package/src/bridge/runtime/text-documents.ts +234 -0
  77. package/src/bridge/runtime.ts +315 -0
  78. package/src/bridge/touch-gesture.ts +159 -0
  79. package/src/bridge/utils/assets.ts +572 -0
  80. package/src/bridge/utils/backends.ts +163 -0
  81. package/src/bridge/utils/encoding.ts +128 -0
  82. package/src/bridge/utils/fetch.ts +147 -0
  83. package/src/bridge/utils/heap.ts +118 -0
  84. package/src/bridge.ts +93 -0
  85. package/src/clipboard.ts +139 -0
  86. package/src/core-types.ts +595 -0
  87. package/src/find-on-page.ts +284 -0
  88. package/src/harness.ts +53 -0
  89. package/src/index.ts +40 -0
  90. package/src/open-canvas.ts +108 -0
  91. package/src/runtime-config.ts +96 -0
  92. package/src/semantic.ts +905 -0
@@ -0,0 +1,905 @@
1
+ import type { SemanticBounds, SemanticNode } from './core-types';
2
+
3
+ const ROLE_NONE = 0;
4
+ const ROLE_BUTTON = 1;
5
+ const ROLE_TEXTBOX = 2;
6
+ const ROLE_LINK = 3;
7
+ const ROLE_HEADING = 4;
8
+ const ROLE_FORM = 5;
9
+ const ROLE_LIST = 6;
10
+ const ROLE_LIST_ITEM = 7;
11
+ const ROLE_IMAGE = 8;
12
+ const ROLE_DIALOG = 9;
13
+ const ROLE_STATIC_TEXT = 10;
14
+ const ROLE_CHECKBOX = 11;
15
+ const ROLE_RADIO = 12;
16
+ const ROLE_RADIO_GROUP = 13;
17
+ const ROLE_SWITCH = 14;
18
+ const ROLE_SLIDER = 15;
19
+ const ROLE_COMBOBOX = 16;
20
+
21
+ const STATE_HAS_SELECTED = 1 << 0;
22
+ const STATE_IS_SELECTED = 1 << 1;
23
+ const STATE_HAS_EXPANDED = 1 << 2;
24
+ const STATE_IS_EXPANDED = 1 << 3;
25
+ const STATE_HAS_DISABLED = 1 << 4;
26
+ const STATE_IS_DISABLED = 1 << 5;
27
+ const STATE_HAS_VALUE_RANGE = 1 << 6;
28
+ const STATE_HAS_READONLY = 1 << 7;
29
+ const STATE_IS_READONLY = 1 << 8;
30
+ const STATE_HAS_MULTILINE = 1 << 9;
31
+ const STATE_IS_MULTILINE = 1 << 10;
32
+
33
+ const CHECKED_NONE = 0;
34
+ const CHECKED_FALSE = 1;
35
+ const CHECKED_TRUE = 2;
36
+ const CHECKED_MIXED = 3;
37
+
38
+ const ORIENTATION_NONE = 0;
39
+ const ORIENTATION_HORIZONTAL = 1;
40
+ const ORIENTATION_VERTICAL = 2;
41
+
42
+ const textDecoder = new TextDecoder();
43
+ const floatWordView = new DataView(new ArrayBuffer(4));
44
+ const textRunFitCache = new WeakMap<HTMLSpanElement, {
45
+ readonly key: string;
46
+ readonly scaleX: number;
47
+ readonly scaleY: number;
48
+ readonly translateX: number;
49
+ readonly translateY: number;
50
+ }>();
51
+
52
+ interface SemanticRoleDescriptor {
53
+ readonly roleName: string;
54
+ readonly tagName: string;
55
+ readonly ariaRole: string | null;
56
+ }
57
+
58
+ interface SemanticTextLayout {
59
+ readonly bounds: SemanticBounds;
60
+ }
61
+
62
+ const SEMANTIC_ROLE_DESCRIPTORS: Record<number, SemanticRoleDescriptor> = {
63
+ [ROLE_NONE]: { roleName: 'none', tagName: 'span', ariaRole: null },
64
+ [ROLE_BUTTON]: { roleName: 'button', tagName: 'button', ariaRole: 'button' },
65
+ [ROLE_TEXTBOX]: { roleName: 'textbox', tagName: 'input', ariaRole: 'textbox' },
66
+ [ROLE_LINK]: { roleName: 'link', tagName: 'a', ariaRole: 'link' },
67
+ [ROLE_HEADING]: { roleName: 'heading', tagName: 'h1', ariaRole: 'heading' },
68
+ [ROLE_FORM]: { roleName: 'form', tagName: 'form', ariaRole: 'form' },
69
+ [ROLE_LIST]: { roleName: 'list', tagName: 'ul', ariaRole: 'list' },
70
+ [ROLE_LIST_ITEM]: { roleName: 'listitem', tagName: 'li', ariaRole: 'listitem' },
71
+ [ROLE_IMAGE]: { roleName: 'img', tagName: 'div', ariaRole: 'img' },
72
+ [ROLE_DIALOG]: { roleName: 'dialog', tagName: 'dialog', ariaRole: 'dialog' },
73
+ [ROLE_STATIC_TEXT]: { roleName: 'text', tagName: 'p', ariaRole: null },
74
+ [ROLE_CHECKBOX]: { roleName: 'checkbox', tagName: 'input', ariaRole: 'checkbox' },
75
+ [ROLE_RADIO]: { roleName: 'radio', tagName: 'input', ariaRole: 'radio' },
76
+ [ROLE_RADIO_GROUP]: { roleName: 'radiogroup', tagName: 'div', ariaRole: 'radiogroup' },
77
+ [ROLE_SWITCH]: { roleName: 'switch', tagName: 'button', ariaRole: 'switch' },
78
+ [ROLE_SLIDER]: { roleName: 'slider', tagName: 'div', ariaRole: 'slider' },
79
+ [ROLE_COMBOBOX]: { roleName: 'combobox', tagName: 'div', ariaRole: 'combobox' },
80
+ };
81
+
82
+ function decodeCheckedState(checkedState: number): 'false' | 'true' | 'mixed' | undefined {
83
+ if (checkedState === CHECKED_FALSE) {
84
+ return 'false';
85
+ }
86
+ if (checkedState === CHECKED_TRUE) {
87
+ return 'true';
88
+ }
89
+ if (checkedState === CHECKED_MIXED) {
90
+ return 'mixed';
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function decodeOrientation(orientation: number): 'horizontal' | 'vertical' | undefined {
96
+ if (orientation === ORIENTATION_HORIZONTAL) {
97
+ return 'horizontal';
98
+ }
99
+ if (orientation === ORIENTATION_VERTICAL) {
100
+ return 'vertical';
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function wordToFloat(word: number): number {
106
+ floatWordView.setUint32(0, word >>> 0, true);
107
+ return floatWordView.getFloat32(0, true);
108
+ }
109
+
110
+ function describeRole(role: number): SemanticRoleDescriptor {
111
+ return SEMANTIC_ROLE_DESCRIPTORS[role] ?? {
112
+ roleName: `unknown-${String(role)}`,
113
+ tagName: 'span',
114
+ ariaRole: null,
115
+ };
116
+ }
117
+
118
+ function resolveTagName(node: SemanticNode, descriptor: SemanticRoleDescriptor): string {
119
+ if (node.role === ROLE_TEXTBOX && node.state.multiline === true) {
120
+ return 'textarea';
121
+ }
122
+ return descriptor.tagName;
123
+ }
124
+
125
+ function decodeLabel(words: Uint32Array, startWordIndex: number, labelLength: number): string {
126
+ if (labelLength === 0) {
127
+ return '';
128
+ }
129
+ const byteOffset = words.byteOffset + (startWordIndex * 4);
130
+ const paddedByteLength = Math.ceil(labelLength / 4) * 4;
131
+ const labelBytes = new Uint8Array(words.buffer, byteOffset, paddedByteLength);
132
+ return textDecoder.decode(labelBytes.subarray(0, labelLength));
133
+ }
134
+
135
+ export function parseSemanticBuffer(words: Uint32Array): SemanticNode[] {
136
+ if (words.length === 0) {
137
+ return [];
138
+ }
139
+
140
+ const recordCount = words[0] ?? 0;
141
+ if (recordCount === 0) {
142
+ return [];
143
+ }
144
+
145
+ let index = 1;
146
+ const nodes: SemanticNode[] = [];
147
+ nodes.length = 0;
148
+
149
+ for (let recordIndex = 0; recordIndex < recordCount; recordIndex += 1) {
150
+ if ((index + 14) > words.length) {
151
+ throw new Error('Semantic buffer ended mid-record.');
152
+ }
153
+
154
+ const role = words[index] ?? ROLE_NONE;
155
+ const handleLow = words[index + 1] ?? 0;
156
+ const handleHigh = words[index + 2] ?? 0;
157
+ const stateFlags = words[index + 7] ?? 0;
158
+ const checkedState = words[index + 8] ?? CHECKED_NONE;
159
+ const orientation = words[index + 9] ?? ORIENTATION_NONE;
160
+ const valueNow = wordToFloat(words[index + 10] ?? 0);
161
+ const valueMin = wordToFloat(words[index + 11] ?? 0);
162
+ const valueMax = wordToFloat(words[index + 12] ?? 0);
163
+ const labelLength = words[index + 13] ?? 0;
164
+ const labelWordCount = Math.ceil(labelLength / 4);
165
+ const descriptor = describeRole(role);
166
+ const handle = ((BigInt(handleHigh) << 32n) | BigInt(handleLow)).toString();
167
+ const bounds: SemanticBounds = {
168
+ x: wordToFloat(words[index + 3] ?? 0),
169
+ y: wordToFloat(words[index + 4] ?? 0),
170
+ width: wordToFloat(words[index + 5] ?? 0),
171
+ height: wordToFloat(words[index + 6] ?? 0),
172
+ };
173
+ const state: {
174
+ checked?: 'false' | 'true' | 'mixed';
175
+ selected?: boolean;
176
+ expanded?: boolean;
177
+ disabled?: boolean;
178
+ readonly?: boolean;
179
+ multiline?: boolean;
180
+ orientation?: 'horizontal' | 'vertical';
181
+ valueNow?: number;
182
+ valueMin?: number;
183
+ valueMax?: number;
184
+ } = {};
185
+ const checked = decodeCheckedState(checkedState);
186
+ if (checked !== undefined) {
187
+ state.checked = checked;
188
+ }
189
+ if ((stateFlags & STATE_HAS_SELECTED) !== 0) {
190
+ state.selected = (stateFlags & STATE_IS_SELECTED) !== 0;
191
+ }
192
+ if ((stateFlags & STATE_HAS_EXPANDED) !== 0) {
193
+ state.expanded = (stateFlags & STATE_IS_EXPANDED) !== 0;
194
+ }
195
+ if ((stateFlags & STATE_HAS_DISABLED) !== 0) {
196
+ state.disabled = (stateFlags & STATE_IS_DISABLED) !== 0;
197
+ }
198
+ if ((stateFlags & STATE_HAS_READONLY) !== 0) {
199
+ state.readonly = (stateFlags & STATE_IS_READONLY) !== 0;
200
+ }
201
+ if ((stateFlags & STATE_HAS_MULTILINE) !== 0) {
202
+ state.multiline = (stateFlags & STATE_IS_MULTILINE) !== 0;
203
+ }
204
+ const decodedOrientation = decodeOrientation(orientation);
205
+ if (decodedOrientation !== undefined) {
206
+ state.orientation = decodedOrientation;
207
+ }
208
+ if ((stateFlags & STATE_HAS_VALUE_RANGE) !== 0) {
209
+ state.valueNow = valueNow;
210
+ state.valueMin = valueMin;
211
+ state.valueMax = valueMax;
212
+ }
213
+ index += 14;
214
+ if ((index + labelWordCount) > words.length) {
215
+ throw new Error('Semantic buffer label exceeded record bounds.');
216
+ }
217
+ const label = decodeLabel(words, index, labelLength);
218
+ index += labelWordCount;
219
+ nodes.push({
220
+ role,
221
+ roleName: descriptor.roleName,
222
+ handle,
223
+ bounds,
224
+ label,
225
+ state,
226
+ });
227
+ }
228
+
229
+ return nodes;
230
+ }
231
+
232
+ function applyNodeFrame(element: HTMLElement, bounds: SemanticBounds): void {
233
+ element.style.left = `${String(bounds.x)}px`;
234
+ element.style.top = `${String(bounds.y)}px`;
235
+ element.style.width = `${String(bounds.width)}px`;
236
+ element.style.height = `${String(bounds.height)}px`;
237
+ }
238
+
239
+ function ensureTextRun(element: HTMLElement): HTMLSpanElement {
240
+ const existing = element.firstElementChild;
241
+ if (existing instanceof HTMLSpanElement && existing.getAttribute('data-semantic-text-run') === 'true') {
242
+ return existing;
243
+ }
244
+ const textRun = document.createElement('span');
245
+ textRun.setAttribute('data-semantic-text-run', 'true');
246
+ textRun.style.position = 'absolute';
247
+ textRun.style.left = '0';
248
+ textRun.style.top = '0';
249
+ textRun.style.display = 'block';
250
+ textRun.style.pointerEvents = 'none';
251
+ textRun.style.margin = '0';
252
+ textRun.style.padding = '0';
253
+ textRun.style.border = '0';
254
+ element.replaceChildren(textRun);
255
+ return textRun;
256
+ }
257
+
258
+ function ensureTextRunContent(textRun: HTMLSpanElement): HTMLSpanElement {
259
+ const existing = textRun.firstElementChild;
260
+ if (existing instanceof HTMLSpanElement && existing.getAttribute('data-semantic-text-content') === 'true') {
261
+ return existing;
262
+ }
263
+ const textContent = document.createElement('span');
264
+ textContent.setAttribute('data-semantic-text-content', 'true');
265
+ textContent.style.display = 'inline-block';
266
+ textContent.style.margin = '0';
267
+ textContent.style.padding = '0';
268
+ textContent.style.border = '0';
269
+ textContent.style.whiteSpace = 'pre-wrap';
270
+ textContent.style.overflowWrap = 'break-word';
271
+ textContent.style.color = 'inherit';
272
+ textContent.style.webkitTextFillColor = 'inherit';
273
+ textContent.style.transformOrigin = 'top left';
274
+ textRun.replaceChildren(textContent);
275
+ return textContent;
276
+ }
277
+
278
+ function ensureTextNode(textContent: HTMLSpanElement): Text {
279
+ if (textContent.childNodes.length === 1) {
280
+ const onlyChild = textContent.firstChild;
281
+ if (onlyChild instanceof Text) {
282
+ return onlyChild;
283
+ }
284
+ }
285
+ const textNode = document.createTextNode('');
286
+ textContent.replaceChildren(textNode);
287
+ return textNode;
288
+ }
289
+
290
+ function clearTextRun(element: HTMLElement): void {
291
+ const existing = element.firstElementChild;
292
+ if (existing instanceof HTMLSpanElement && existing.getAttribute('data-semantic-text-run') === 'true') {
293
+ textRunFitCache.delete(existing);
294
+ existing.remove();
295
+ }
296
+ }
297
+
298
+ function applyTextRunLayout(
299
+ textRun: HTMLSpanElement,
300
+ nodeBounds: SemanticBounds,
301
+ textLayout: SemanticTextLayout | undefined,
302
+ ): void {
303
+ if (textLayout === undefined) {
304
+ textRunFitCache.delete(textRun);
305
+ const textContent = ensureTextRunContent(textRun);
306
+ textRun.style.left = '0';
307
+ textRun.style.top = '0';
308
+ textRun.style.width = '';
309
+ textRun.style.height = '';
310
+ textRun.style.overflow = 'visible';
311
+ textContent.style.width = '';
312
+ textContent.style.height = '';
313
+ textContent.style.transform = '';
314
+ return;
315
+ }
316
+
317
+ const textContent = ensureTextRunContent(textRun);
318
+ const offsetX = textLayout.bounds.x - nodeBounds.x;
319
+ const offsetY = textLayout.bounds.y - nodeBounds.y;
320
+ textRun.style.left = `${String(offsetX)}px`;
321
+ textRun.style.top = `${String(offsetY)}px`;
322
+ textRun.style.width = `${String(textLayout.bounds.width)}px`;
323
+ textRun.style.height = `${String(textLayout.bounds.height)}px`;
324
+ textRun.style.overflow = 'hidden';
325
+ textContent.style.width = `${String(textLayout.bounds.width)}px`;
326
+ textContent.style.height = '';
327
+ textContent.style.transform = '';
328
+ const firstChild = textContent.firstChild;
329
+ const textValue = firstChild instanceof Text ? firstChild.data : '';
330
+ const fitKey = [
331
+ textValue,
332
+ textLayout.bounds.width.toFixed(3),
333
+ textLayout.bounds.height.toFixed(3),
334
+ ].join('\u001f');
335
+ const cachedFit = textRunFitCache.get(textRun);
336
+ if (cachedFit?.key === fitKey) {
337
+ textContent.style.transform = `matrix(${String(cachedFit.scaleX)}, 0, 0, ${String(cachedFit.scaleY)}, ${String(cachedFit.translateX)}, ${String(cachedFit.translateY)})`;
338
+ return;
339
+ }
340
+ const measuredChild = textContent.firstChild;
341
+ let scaleX = 1;
342
+ let scaleY = 1;
343
+ let translateX = 0;
344
+ let translateY = 0;
345
+ if (measuredChild instanceof Text && measuredChild.data.length > 0) {
346
+ const range = document.createRange();
347
+ range.selectNodeContents(measuredChild);
348
+ const measured = range.getBoundingClientRect();
349
+ const contentRect = textContent.getBoundingClientRect();
350
+ const nextScaleX = measured.width > 0 ? textLayout.bounds.width / measured.width : 1;
351
+ const nextScaleY = measured.height > 0 ? textLayout.bounds.height / measured.height : 1;
352
+ scaleX = Number.isFinite(nextScaleX) && nextScaleX > 0 ? nextScaleX : 1;
353
+ scaleY = Number.isFinite(nextScaleY) && nextScaleY > 0 ? nextScaleY : 1;
354
+ const localX = measured.x - contentRect.x;
355
+ const localY = measured.y - contentRect.y;
356
+ translateX = Number.isFinite(localX) ? -(localX * scaleX) : 0;
357
+ translateY = Number.isFinite(localY) ? -(localY * scaleY) : 0;
358
+ }
359
+ textContent.style.transform = `matrix(${String(scaleX)}, 0, 0, ${String(scaleY)}, ${String(translateX)}, ${String(translateY)})`;
360
+ textRunFitCache.set(textRun, {
361
+ key: fitKey,
362
+ scaleX,
363
+ scaleY,
364
+ translateX,
365
+ translateY,
366
+ });
367
+ }
368
+
369
+ function syncOrderedChildren(container: HTMLElement, orderedElements: readonly HTMLElement[]): void {
370
+ let nextChild = container.firstElementChild as HTMLElement | null;
371
+ for (const element of orderedElements) {
372
+ if (element === nextChild) {
373
+ nextChild = nextChild.nextElementSibling as HTMLElement | null;
374
+ continue;
375
+ }
376
+ container.insertBefore(element, nextChild);
377
+ }
378
+ while (nextChild !== null) {
379
+ const stale = nextChild;
380
+ nextChild = stale.nextElementSibling as HTMLElement | null;
381
+ stale.remove();
382
+ }
383
+ }
384
+
385
+ function boundsContain(container: SemanticBounds, candidate: SemanticBounds): boolean {
386
+ return candidate.x >= container.x &&
387
+ candidate.y >= container.y &&
388
+ (candidate.x + candidate.width) <= (container.x + container.width) &&
389
+ (candidate.y + candidate.height) <= (container.y + container.height);
390
+ }
391
+
392
+ function ensureProjectedElement(
393
+ layer: HTMLElement,
394
+ byHandle: Map<string, HTMLElement>,
395
+ node: SemanticNode,
396
+ ): HTMLElement {
397
+ const descriptor = describeRole(node.role);
398
+ const tagName = resolveTagName(node, descriptor);
399
+ const existing = byHandle.get(node.handle);
400
+ if (existing?.tagName.toLowerCase() === tagName) {
401
+ return existing;
402
+ }
403
+
404
+ const created = document.createElement(tagName);
405
+ created.setAttribute('data-handle', node.handle);
406
+ created.style.position = 'absolute';
407
+ created.style.pointerEvents = 'none';
408
+ created.style.margin = '0';
409
+ created.style.padding = '0';
410
+ created.style.boxSizing = 'border-box';
411
+ created.style.background = 'transparent';
412
+ created.style.border = '0';
413
+ created.style.outline = 'none';
414
+ created.style.appearance = 'none';
415
+ created.style.webkitAppearance = 'none';
416
+ created.style.color = 'transparent';
417
+ created.style.webkitTextFillColor = 'transparent';
418
+ created.style.caretColor = 'transparent';
419
+ created.style.lineHeight = 'normal';
420
+ created.tabIndex = -1;
421
+
422
+ if (tagName === 'input') {
423
+ const input = created as HTMLInputElement;
424
+ if (node.role === ROLE_CHECKBOX) {
425
+ input.type = 'checkbox';
426
+ } else if (node.role === ROLE_RADIO) {
427
+ input.type = 'radio';
428
+ } else {
429
+ input.type = 'text';
430
+ }
431
+ input.readOnly = node.state.readonly ?? true;
432
+ } else if (tagName === 'textarea') {
433
+ const textarea = created as HTMLTextAreaElement;
434
+ textarea.readOnly = node.state.readonly ?? true;
435
+ textarea.rows = 1;
436
+ textarea.style.resize = 'none';
437
+ }
438
+
439
+ if (node.role === ROLE_TEXTBOX && node.state.multiline === true) {
440
+ created.setAttribute('aria-multiline', 'true');
441
+ }
442
+
443
+ if (existing?.parentElement === layer) {
444
+ layer.replaceChild(created, existing);
445
+ } else {
446
+ layer.appendChild(created);
447
+ }
448
+
449
+ byHandle.set(node.handle, created);
450
+ return created;
451
+ }
452
+
453
+ function cloneNode(node: SemanticNode): SemanticNode {
454
+ return {
455
+ role: node.role,
456
+ roleName: node.roleName,
457
+ handle: node.handle,
458
+ bounds: {
459
+ x: node.bounds.x,
460
+ y: node.bounds.y,
461
+ width: node.bounds.width,
462
+ height: node.bounds.height,
463
+ },
464
+ label: node.label,
465
+ state: { ...node.state },
466
+ };
467
+ }
468
+
469
+ function roleNeedsAriaLabel(role: number): boolean {
470
+ return role === ROLE_BUTTON ||
471
+ role === ROLE_TEXTBOX ||
472
+ role === ROLE_IMAGE ||
473
+ role === ROLE_DIALOG ||
474
+ role === ROLE_CHECKBOX ||
475
+ role === ROLE_RADIO ||
476
+ role === ROLE_SLIDER ||
477
+ role === ROLE_COMBOBOX;
478
+ }
479
+
480
+ function roleUsesTextContent(role: number): boolean {
481
+ return role !== ROLE_BUTTON && role !== ROLE_IMAGE;
482
+ }
483
+
484
+ function describeAnnouncementRole(node: SemanticNode): string {
485
+ switch (node.role) {
486
+ case ROLE_BUTTON:
487
+ return 'button';
488
+ case ROLE_TEXTBOX:
489
+ return node.state.multiline === true ? 'text area' : 'text input';
490
+ case ROLE_LINK:
491
+ return 'link';
492
+ case ROLE_HEADING:
493
+ return 'heading';
494
+ case ROLE_FORM:
495
+ return 'form';
496
+ case ROLE_LIST:
497
+ return 'list';
498
+ case ROLE_LIST_ITEM:
499
+ return 'list item';
500
+ case ROLE_IMAGE:
501
+ return 'image';
502
+ case ROLE_DIALOG:
503
+ return 'dialog';
504
+ case ROLE_STATIC_TEXT:
505
+ return 'text';
506
+ case ROLE_CHECKBOX:
507
+ return 'checkbox';
508
+ case ROLE_RADIO:
509
+ return 'radio button';
510
+ case ROLE_RADIO_GROUP:
511
+ return 'radio group';
512
+ case ROLE_SWITCH:
513
+ return 'switch';
514
+ case ROLE_SLIDER:
515
+ return 'slider';
516
+ case ROLE_COMBOBOX:
517
+ return 'combo box';
518
+ default:
519
+ return node.roleName;
520
+ }
521
+ }
522
+
523
+ function buildNodeAnnouncement(
524
+ node: SemanticNode,
525
+ textByHandle: Readonly<Record<string, string>>,
526
+ ): string {
527
+ const parts: string[] = [];
528
+ const label = node.role === ROLE_TEXTBOX && node.label.length === 0
529
+ ? (textByHandle[node.handle] ?? '')
530
+ : node.label;
531
+ if (label.length > 0) {
532
+ parts.push(label);
533
+ }
534
+ if (!(node.role === ROLE_SLIDER && label.length > 0)) {
535
+ parts.push(describeAnnouncementRole(node));
536
+ }
537
+ if (node.state.checked === 'mixed') {
538
+ parts.push('mixed');
539
+ } else if (node.state.checked === 'true') {
540
+ parts.push('checked');
541
+ } else if (
542
+ node.state.checked === 'false' &&
543
+ (node.role === ROLE_CHECKBOX || node.role === ROLE_RADIO || node.role === ROLE_SWITCH)
544
+ ) {
545
+ parts.push('unchecked');
546
+ }
547
+ if (node.state.selected === true) {
548
+ parts.push('selected');
549
+ }
550
+ if (node.state.expanded === true) {
551
+ parts.push('expanded');
552
+ } else if (node.state.expanded === false) {
553
+ parts.push('collapsed');
554
+ }
555
+ if (node.state.readonly === true) {
556
+ parts.push('read only');
557
+ }
558
+ if (node.state.disabled === true) {
559
+ parts.push('disabled');
560
+ }
561
+ if (node.state.valueNow !== undefined && Number.isFinite(node.state.valueNow)) {
562
+ parts.push(`value ${String(node.state.valueNow)}`);
563
+ }
564
+ return parts.join(', ');
565
+ }
566
+
567
+ class LiveAnnouncer {
568
+ private readonly element: HTMLOutputElement;
569
+ private pendingFrame: number | null = null;
570
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
571
+ private clearTimer: ReturnType<typeof setTimeout> | null = null;
572
+
573
+ public constructor(root: ShadowRoot) {
574
+ const element = document.createElement('output');
575
+ element.id = 'semantic-live-announcer';
576
+ element.setAttribute('role', 'status');
577
+ element.setAttribute('aria-live', 'polite');
578
+ element.setAttribute('aria-atomic', 'true');
579
+ element.setAttribute('data-effindom-live-announcer', 'true');
580
+ element.style.position = 'absolute';
581
+ element.style.width = '1px';
582
+ element.style.height = '1px';
583
+ element.style.margin = '-1px';
584
+ element.style.padding = '0';
585
+ element.style.border = '0';
586
+ element.style.overflow = 'hidden';
587
+ element.style.clip = 'rect(0 0 0 0)';
588
+ element.style.clipPath = 'inset(50%)';
589
+ element.style.whiteSpace = 'nowrap';
590
+ element.style.opacity = '0';
591
+ element.textContent = '\u00A0';
592
+ root.appendChild(element);
593
+ this.element = element;
594
+ }
595
+
596
+ public announce(message: string): void {
597
+ if (message.length === 0) {
598
+ return;
599
+ }
600
+ if (this.pendingFrame !== null) {
601
+ cancelAnimationFrame(this.pendingFrame);
602
+ this.pendingFrame = null;
603
+ }
604
+ if (this.pendingTimer !== null) {
605
+ clearTimeout(this.pendingTimer);
606
+ this.pendingTimer = null;
607
+ }
608
+ if (this.clearTimer !== null) {
609
+ clearTimeout(this.clearTimer);
610
+ this.clearTimer = null;
611
+ }
612
+ this.element.textContent = '\u00A0';
613
+ this.pendingFrame = requestAnimationFrame(() => {
614
+ this.pendingFrame = null;
615
+ this.pendingTimer = setTimeout(() => {
616
+ this.pendingTimer = null;
617
+ this.element.textContent = message;
618
+ this.clearTimer = setTimeout(() => {
619
+ this.clearTimer = null;
620
+ this.element.textContent = '\u00A0';
621
+ }, 1500);
622
+ }, 50);
623
+ });
624
+ }
625
+
626
+ public destroy(): void {
627
+ if (this.pendingFrame !== null) {
628
+ cancelAnimationFrame(this.pendingFrame);
629
+ this.pendingFrame = null;
630
+ }
631
+ if (this.pendingTimer !== null) {
632
+ clearTimeout(this.pendingTimer);
633
+ this.pendingTimer = null;
634
+ }
635
+ if (this.clearTimer !== null) {
636
+ clearTimeout(this.clearTimer);
637
+ this.clearTimer = null;
638
+ }
639
+ this.element.remove();
640
+ }
641
+ }
642
+
643
+ export class HiddenDomProjector {
644
+ private readonly canvas: HTMLCanvasElement;
645
+ private readonly shell: HTMLDivElement;
646
+ private readonly layer: HTMLDivElement;
647
+ private readonly content: HTMLDivElement;
648
+ private readonly announcer: LiveAnnouncer;
649
+ private readonly elementsByHandle = new Map<string, HTMLElement>();
650
+
651
+ public constructor(canvas: HTMLCanvasElement) {
652
+ const parent = canvas.parentElement;
653
+ if (!(parent instanceof HTMLElement)) {
654
+ throw new Error('Expected canvas parent element for semantic projection.');
655
+ }
656
+
657
+ const shell = document.createElement('div');
658
+ shell.id = 'scene-shell';
659
+ shell.style.position = 'relative';
660
+ shell.style.display = 'inline-block';
661
+ shell.style.lineHeight = '0';
662
+ parent.replaceChild(shell, canvas);
663
+
664
+ const layer = document.createElement('div');
665
+ layer.id = 'semantic-layer';
666
+ layer.style.position = 'absolute';
667
+ layer.style.left = '0';
668
+ layer.style.top = '0';
669
+ layer.style.pointerEvents = 'none';
670
+ layer.style.overflow = 'hidden';
671
+ layer.style.lineHeight = 'normal';
672
+ layer.setAttribute('data-visibility', 'screen-reader-only');
673
+ const shadowRoot = layer.attachShadow({ mode: 'open' });
674
+
675
+ const content = document.createElement('div');
676
+ content.id = 'semantic-content';
677
+ content.style.position = 'absolute';
678
+ content.style.left = '0';
679
+ content.style.top = '0';
680
+ content.style.width = '100%';
681
+ content.style.height = '100%';
682
+ content.style.padding = '0';
683
+ content.style.border = '0';
684
+ content.style.overflow = 'hidden';
685
+ content.style.whiteSpace = 'nowrap';
686
+ content.style.color = 'transparent';
687
+ content.style.webkitTextFillColor = 'transparent';
688
+ content.style.lineHeight = 'normal';
689
+ shadowRoot.appendChild(content);
690
+ const announcer = new LiveAnnouncer(shadowRoot);
691
+
692
+ shell.appendChild(canvas);
693
+ shell.appendChild(layer);
694
+
695
+ canvas.setAttribute('role', 'application');
696
+ canvas.setAttribute('aria-label', 'EffinDom application');
697
+
698
+ this.canvas = canvas;
699
+ this.shell = shell;
700
+ this.layer = layer;
701
+ this.content = content;
702
+ this.announcer = announcer;
703
+ }
704
+
705
+ public syncSize(logicalWidth: number, logicalHeight: number): void {
706
+ const width = `${String(logicalWidth)}px`;
707
+ const height = `${String(logicalHeight)}px`;
708
+ this.shell.style.width = width;
709
+ this.shell.style.height = height;
710
+ this.layer.style.width = width;
711
+ this.layer.style.height = height;
712
+ }
713
+
714
+ public update(
715
+ nodes: readonly SemanticNode[],
716
+ textByHandle: Readonly<Record<string, string>>,
717
+ textLayoutsByHandle: Readonly<Record<string, SemanticTextLayout | undefined>>,
718
+ ): void {
719
+ const seenHandles = new Set<string>();
720
+ const orderedElements: HTMLElement[] = [];
721
+
722
+ for (const node of nodes) {
723
+ seenHandles.add(node.handle);
724
+ const descriptor = describeRole(node.role);
725
+ const element = ensureProjectedElement(this.content, this.elementsByHandle, node);
726
+ orderedElements.push(element);
727
+ const label = node.role === ROLE_TEXTBOX && node.label.length === 0
728
+ ? (textByHandle[node.handle] ?? '')
729
+ : node.label;
730
+
731
+ if (descriptor.ariaRole === null) {
732
+ element.removeAttribute('role');
733
+ } else {
734
+ element.setAttribute('role', descriptor.ariaRole);
735
+ }
736
+ if (label.length === 0 || !roleNeedsAriaLabel(node.role)) {
737
+ element.removeAttribute('aria-label');
738
+ } else {
739
+ element.setAttribute('aria-label', label);
740
+ }
741
+ if (node.role === ROLE_DIALOG) {
742
+ element.setAttribute('aria-modal', 'true');
743
+ if (element instanceof HTMLDialogElement) {
744
+ element.setAttribute('open', '');
745
+ }
746
+ } else {
747
+ element.removeAttribute('aria-modal');
748
+ }
749
+ if (node.state.checked === undefined) {
750
+ element.removeAttribute('aria-checked');
751
+ } else {
752
+ element.setAttribute('aria-checked', node.state.checked);
753
+ }
754
+ if (node.state.selected === undefined) {
755
+ element.removeAttribute('aria-selected');
756
+ } else {
757
+ element.setAttribute('aria-selected', node.state.selected ? 'true' : 'false');
758
+ }
759
+ if (node.state.expanded === undefined) {
760
+ element.removeAttribute('aria-expanded');
761
+ } else {
762
+ element.setAttribute('aria-expanded', node.state.expanded ? 'true' : 'false');
763
+ }
764
+ if (node.state.disabled === undefined) {
765
+ element.removeAttribute('aria-disabled');
766
+ } else {
767
+ element.setAttribute('aria-disabled', node.state.disabled ? 'true' : 'false');
768
+ }
769
+ if (node.state.readonly === undefined) {
770
+ element.removeAttribute('aria-readonly');
771
+ } else {
772
+ element.setAttribute('aria-readonly', node.state.readonly ? 'true' : 'false');
773
+ }
774
+ if (node.state.multiline === undefined) {
775
+ element.removeAttribute('aria-multiline');
776
+ } else {
777
+ element.setAttribute('aria-multiline', node.state.multiline ? 'true' : 'false');
778
+ }
779
+ if (node.state.orientation === undefined) {
780
+ element.removeAttribute('aria-orientation');
781
+ } else {
782
+ element.setAttribute('aria-orientation', node.state.orientation);
783
+ }
784
+ if (node.state.valueNow === undefined) {
785
+ element.removeAttribute('aria-valuenow');
786
+ element.removeAttribute('aria-valuemin');
787
+ element.removeAttribute('aria-valuemax');
788
+ } else {
789
+ element.setAttribute('aria-valuenow', String(node.state.valueNow));
790
+ element.setAttribute('aria-valuemin', String(node.state.valueMin ?? 0));
791
+ element.setAttribute('aria-valuemax', String(node.state.valueMax ?? 0));
792
+ }
793
+ element.id = `semantic-node-${node.handle}`;
794
+ element.setAttribute('data-role', node.roleName);
795
+ applyNodeFrame(element, node.bounds);
796
+
797
+ if (element instanceof HTMLInputElement) {
798
+ if (node.role === ROLE_CHECKBOX) {
799
+ element.checked = node.state.checked === 'true';
800
+ element.indeterminate = node.state.checked === 'mixed';
801
+ } else if (node.role === ROLE_RADIO) {
802
+ element.checked = node.state.checked === 'true';
803
+ } else {
804
+ const nextValue = textByHandle[node.handle] ?? '';
805
+ if (element.value !== nextValue) {
806
+ element.value = nextValue;
807
+ }
808
+ element.readOnly = node.state.readonly ?? true;
809
+ }
810
+ } else if (element instanceof HTMLTextAreaElement) {
811
+ const nextValue = textByHandle[node.handle] ?? '';
812
+ if (element.value !== nextValue) {
813
+ element.value = nextValue;
814
+ }
815
+ element.readOnly = node.state.readonly ?? true;
816
+ } else if (roleUsesTextContent(node.role)) {
817
+ const textRun = ensureTextRun(element);
818
+ const textNode = ensureTextNode(ensureTextRunContent(textRun));
819
+ if (textNode.data !== label) {
820
+ textNode.data = label;
821
+ }
822
+ applyTextRunLayout(textRun, node.bounds, textLayoutsByHandle[node.handle]);
823
+ } else {
824
+ clearTextRun(element);
825
+ if (element.textContent !== '') {
826
+ element.textContent = '';
827
+ }
828
+ }
829
+ }
830
+
831
+ for (const node of nodes) {
832
+ if (node.role !== ROLE_DIALOG) {
833
+ continue;
834
+ }
835
+ const dialogElement = this.elementsByHandle.get(node.handle);
836
+ if (!(dialogElement instanceof HTMLElement)) {
837
+ continue;
838
+ }
839
+
840
+ const heading = nodes.find((candidate) =>
841
+ candidate.handle !== node.handle &&
842
+ candidate.role === ROLE_HEADING &&
843
+ boundsContain(node.bounds, candidate.bounds));
844
+ const descriptionNodes = nodes.filter((candidate) =>
845
+ candidate.handle !== node.handle &&
846
+ candidate.role === ROLE_STATIC_TEXT &&
847
+ boundsContain(node.bounds, candidate.bounds));
848
+
849
+ if (heading !== undefined) {
850
+ dialogElement.setAttribute('aria-labelledby', `semantic-node-${heading.handle}`);
851
+ } else {
852
+ dialogElement.removeAttribute('aria-labelledby');
853
+ }
854
+
855
+ if (descriptionNodes.length > 0) {
856
+ dialogElement.setAttribute(
857
+ 'aria-describedby',
858
+ descriptionNodes.map((candidate) => `semantic-node-${candidate.handle}`).join(' '),
859
+ );
860
+ } else {
861
+ dialogElement.removeAttribute('aria-describedby');
862
+ }
863
+ }
864
+
865
+ for (const [handle, element] of this.elementsByHandle.entries()) {
866
+ if (seenHandles.has(handle)) {
867
+ continue;
868
+ }
869
+ element.remove();
870
+ this.elementsByHandle.delete(handle);
871
+ }
872
+ syncOrderedChildren(this.content, orderedElements);
873
+ }
874
+
875
+ public announceNode(
876
+ handle: string,
877
+ nodes: readonly SemanticNode[],
878
+ textByHandle: Readonly<Record<string, string>>,
879
+ ): void {
880
+ const node = nodes.find((candidate) => candidate.handle === handle);
881
+ if (node === undefined) {
882
+ return;
883
+ }
884
+ this.announcer.announce(buildNodeAnnouncement(node, textByHandle));
885
+ }
886
+
887
+ public destroy(): void {
888
+ this.announcer.destroy();
889
+ for (const element of this.elementsByHandle.values()) {
890
+ element.remove();
891
+ }
892
+ this.elementsByHandle.clear();
893
+ this.layer.remove();
894
+ const parent = this.shell.parentElement;
895
+ if (parent !== null) {
896
+ parent.replaceChild(this.canvas, this.shell);
897
+ } else {
898
+ this.shell.remove();
899
+ }
900
+ }
901
+ }
902
+
903
+ export function cloneSemanticTree(nodes: readonly SemanticNode[]): SemanticNode[] {
904
+ return nodes.map((node) => cloneNode(node));
905
+ }