@ccheever/exact-renderer 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 (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,1459 @@
1
+ // @system @ref LLP 0157 — DOM mirror: a web host for host-ops adapters.
2
+ //
3
+ // Framework adapters built on the shared host-ops layer (Solid, Vue,
4
+ // Svelte, Contract) emit binary protocol batches that only a platform host
5
+ // consumes (`exact.dispatch`). On a plain browser tab no host exists, so
6
+ // those surfaces render nothing — only the React-DOM path has pixels.
7
+ //
8
+ // The mirror closes that gap for development surfaces: after every commit
9
+ // it reconciles the JS-side host-ops node tree (ElementNode/TextNode with
10
+ // `originalProps`) into real DOM under the app container, mapping RN-style
11
+ // styles to CSS (browser flexbox does the layout) and wiring DOM events
12
+ // back into the adapters' handler props — including hover, which the
13
+ // protocol path cannot deliver today.
14
+ //
15
+ // Scope: a development-grade mirror for labs and demos on the plain-web
16
+ // path. It deliberately skips the binary protocol (no decode); the node
17
+ // tree is already in process. When a real host is present
18
+ // (`exact.dispatch` is a function), installation is a no-op so the
19
+ // platform keeps ownership of pixels.
20
+
21
+ import { colorToRgba, parseColor } from '@exact/core/style/color';
22
+
23
+ import {
24
+ isSafeHostDomAttr,
25
+ lowerHostAttrs,
26
+ type DomTagName,
27
+ type HostSemanticAttrs,
28
+ } from './attrs.js';
29
+ import {
30
+ getHostChannelStyleApplier,
31
+ setHostChannelStyleApplier,
32
+ type HostChannelStyleApplier,
33
+ } from './host-ops.js';
34
+ import { getRuntimeImageColorScheme } from './image-source.js';
35
+ import type { ElementNode, RootNode, TextNode } from './nodes/node.js';
36
+ import { NodeKind } from './nodes/node.js';
37
+
38
+ export type DomMirrorHostChild = ElementNode | TextNode;
39
+ type HostChild = DomMirrorHostChild;
40
+
41
+ interface DomMirrorRegistry {
42
+ sync(root: RootNode): void;
43
+ remove(rootId: number): void;
44
+ }
45
+
46
+ export interface DomIdentityRegistry {
47
+ elementForViewId(viewId: number): HTMLElement | null;
48
+ viewIdForTarget(target: EventTarget | null): number | null;
49
+ }
50
+
51
+ declare global {
52
+ // eslint-disable-next-line no-var
53
+ var __exactDomMirror: DomMirrorRegistry | undefined;
54
+ // eslint-disable-next-line no-var
55
+ var __exactDomIdentity: DomIdentityRegistry | undefined;
56
+ // eslint-disable-next-line no-var
57
+ var __exactDomHydrationAdopterFactory:
58
+ | ((options: DomHydrationAdopterFactoryOptions) => DomHydrationAdopter | null)
59
+ | undefined;
60
+ }
61
+
62
+ export interface DomMirrorOptions {
63
+ /** Container for root 0 (default: #exact-root, else document.body). */
64
+ container?: HTMLElement;
65
+ /** Install even when a protocol host (exact.dispatch) is present. */
66
+ force?: boolean;
67
+ /** Optional DOM adoption data for a pre-rendered Contract web root. */
68
+ hydrate?: DomHydrationOptions;
69
+ }
70
+
71
+ export interface DomMirrorHandle {
72
+ dispose(): void;
73
+ }
74
+
75
+ export interface DomHydrationManifestLike {
76
+ version?: number;
77
+ rootId?: number;
78
+ nodes?: Array<{
79
+ nodeId?: string;
80
+ domPath?: number[];
81
+ kind?: string;
82
+ tag?: string;
83
+ textGuard?: string;
84
+ }>;
85
+ }
86
+
87
+ export interface DomHydrationResult {
88
+ rootId: number;
89
+ adoptedNodes: number;
90
+ recreatedNodes: number;
91
+ mismatches: string[];
92
+ }
93
+
94
+ export interface DomHydrationOptions {
95
+ rootId: number;
96
+ manifest: DomHydrationManifestLike;
97
+ onResult?: (result: DomHydrationResult) => void;
98
+ }
99
+
100
+ export interface DomHydrationAdopterFactoryOptions {
101
+ hydrate: DomHydrationOptions;
102
+ markMirrored(node: Node): void;
103
+ }
104
+
105
+ export interface DomHydrationAdopter {
106
+ adoptRootContainer(
107
+ rootId: number,
108
+ base: HTMLElement,
109
+ rootAttribute: string,
110
+ ): HTMLElement | null;
111
+ adoptDom(
112
+ child: DomMirrorHostChild,
113
+ rootId: number,
114
+ rootContainer: HTMLElement,
115
+ desiredTag?: DomTagName,
116
+ ): Node | null;
117
+ report(rootId: number): void;
118
+ }
119
+
120
+ export interface ContractWebHostOptions extends DomMirrorOptions {}
121
+
122
+ export interface ContractWebHostHandle extends DomMirrorHandle {}
123
+
124
+ interface DomHostRuntimeOptions {
125
+ exposeDevIdentity: boolean;
126
+ rootAttribute: 'data-exact-mirror-root' | 'data-exact-contract-host-root';
127
+ }
128
+
129
+ function asDomNode(target: EventTarget | null): Node | null {
130
+ if (
131
+ target &&
132
+ typeof target === 'object' &&
133
+ 'parentNode' in target &&
134
+ 'nodeType' in target
135
+ ) {
136
+ return target as Node;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function hasProtocolHost(): boolean {
142
+ const runtime = globalThis as typeof globalThis & {
143
+ exact?: { dispatch?: unknown };
144
+ };
145
+ return typeof runtime.exact?.dispatch === 'function';
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Style conversion: RN-ish style objects -> CSS
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const UNITLESS = new Set([
153
+ 'flex',
154
+ 'flexGrow',
155
+ 'flexShrink',
156
+ 'opacity',
157
+ 'zIndex',
158
+ 'fontWeight',
159
+ 'aspectRatio',
160
+ 'order',
161
+ ]);
162
+
163
+ const TRANSITION_PROPERTY_CSS: Record<string, string> = {
164
+ opacity: 'opacity',
165
+ transform: 'transform',
166
+ backgroundColor: 'background-color',
167
+ borderRadius: 'border-radius',
168
+ };
169
+
170
+ function cssEasing(easing: unknown): string {
171
+ if (Array.isArray(easing) && easing.length === 4) {
172
+ return `cubic-bezier(${easing.join(', ')})`;
173
+ }
174
+ switch (easing) {
175
+ case 'easeIn':
176
+ return 'ease-in';
177
+ case 'easeOut':
178
+ return 'ease-out';
179
+ case 'easeInOut':
180
+ return 'ease-in-out';
181
+ case 'linear':
182
+ default:
183
+ return 'linear';
184
+ }
185
+ }
186
+
187
+ // Shared with web-primitives.tsx so the React-DOM path and the mirror agree
188
+ // on how Exact transition maps / transform arrays become CSS.
189
+ export function cssTransition(value: unknown): string | null {
190
+ if (typeof value === 'string') {
191
+ return value;
192
+ }
193
+ if (!value || typeof value !== 'object') {
194
+ return null;
195
+ }
196
+ const parts: string[] = [];
197
+ for (const [property, config] of Object.entries(value as Record<string, unknown>)) {
198
+ const cssProperty = TRANSITION_PROPERTY_CSS[property];
199
+ if (!cssProperty || !config || typeof config !== 'object') {
200
+ continue;
201
+ }
202
+ const transition = config as { type?: string; duration?: number; easing?: unknown };
203
+ if (transition.type === 'none') {
204
+ continue;
205
+ }
206
+ if (transition.type === 'spring') {
207
+ // Springs approximate to a fast ease-out curve on the mirror.
208
+ parts.push(`${cssProperty} ${transition.duration ?? 300}ms cubic-bezier(0.22, 1, 0.36, 1)`);
209
+ } else {
210
+ parts.push(`${cssProperty} ${transition.duration ?? 200}ms ${cssEasing(transition.easing)}`);
211
+ }
212
+ }
213
+ return parts.length > 0 ? parts.join(', ') : null;
214
+ }
215
+
216
+ export function cssTransform(value: unknown): string | null {
217
+ if (typeof value === 'string') {
218
+ return value;
219
+ }
220
+ if (!Array.isArray(value)) {
221
+ return null;
222
+ }
223
+ const parts: string[] = [];
224
+ for (const entry of value) {
225
+ if (!entry || typeof entry !== 'object') {
226
+ continue;
227
+ }
228
+ for (const [op, amount] of Object.entries(entry as Record<string, unknown>)) {
229
+ if (op === 'translateX' || op === 'translateY') {
230
+ parts.push(`${op}(${typeof amount === 'number' ? `${amount}px` : String(amount)})`);
231
+ } else if (op === 'scale' || op === 'scaleX' || op === 'scaleY') {
232
+ parts.push(`${op}(${String(amount)})`);
233
+ } else if (op === 'rotate') {
234
+ parts.push(`rotate(${String(amount)})`);
235
+ }
236
+ }
237
+ }
238
+ return parts.length > 0 ? parts.join(' ') : null;
239
+ }
240
+
241
+ function cssValue(name: string, value: unknown): string {
242
+ if (typeof value === 'number') {
243
+ return UNITLESS.has(name) ? String(value) : `${value}px`;
244
+ }
245
+ if (Array.isArray(value)) {
246
+ // e.g. fontFamily stacks
247
+ return value.join(', ');
248
+ }
249
+ return String(value);
250
+ }
251
+
252
+ function readPositiveLineCount(value: unknown): number | undefined {
253
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
254
+ ? Math.trunc(value)
255
+ : undefined;
256
+ }
257
+
258
+ function applyTextLineClampStyles(
259
+ el: HTMLElement,
260
+ element: ElementNode,
261
+ props: Record<string, unknown>,
262
+ ): void {
263
+ if (!TEXT_TAGS.has(element.originalTag)) {
264
+ return;
265
+ }
266
+
267
+ const styleObject =
268
+ typeof props.style === 'object' && props.style !== null
269
+ ? (props.style as Record<string, unknown>)
270
+ : undefined;
271
+ const numberOfLines =
272
+ readPositiveLineCount(props.numberOfLines) ??
273
+ readPositiveLineCount(styleObject?.numberOfLines) ??
274
+ readPositiveLineCount(element.style.numberOfLines);
275
+ if (numberOfLines == null) {
276
+ return;
277
+ }
278
+
279
+ if (numberOfLines === 1) {
280
+ el.style.setProperty('display', 'block');
281
+ el.style.setProperty('overflow', 'hidden');
282
+ el.style.setProperty('text-overflow', 'ellipsis');
283
+ el.style.setProperty('white-space', 'nowrap');
284
+ return;
285
+ }
286
+
287
+ el.style.setProperty('display', '-webkit-box');
288
+ el.style.setProperty('-webkit-line-clamp', String(numberOfLines));
289
+ el.style.setProperty('-webkit-box-orient', 'vertical');
290
+ el.style.setProperty('overflow', 'hidden');
291
+ }
292
+
293
+ /** Expands RN-only shorthand/composite props into CSS-compatible entries. */
294
+ function convertStyle(style: Record<string, unknown> | undefined): Record<string, string> {
295
+ const out: Record<string, string> = {};
296
+ if (!style) {
297
+ return out;
298
+ }
299
+
300
+ let shadow: { x?: number; y?: number; blur?: number; color?: string; opacity?: number } | null = null;
301
+
302
+ for (const [name, value] of Object.entries(style)) {
303
+ if (value === undefined || value === null) {
304
+ continue;
305
+ }
306
+ switch (name) {
307
+ case 'transition': {
308
+ const converted = cssTransition(value);
309
+ if (converted) {
310
+ out.transition = converted;
311
+ }
312
+ break;
313
+ }
314
+ case 'transform': {
315
+ const converted = cssTransform(value);
316
+ if (converted) {
317
+ out.transform = converted;
318
+ }
319
+ break;
320
+ }
321
+ case 'paddingHorizontal':
322
+ out.paddingLeft = cssValue('paddingLeft', value);
323
+ out.paddingRight = cssValue('paddingRight', value);
324
+ break;
325
+ case 'paddingVertical':
326
+ out.paddingTop = cssValue('paddingTop', value);
327
+ out.paddingBottom = cssValue('paddingBottom', value);
328
+ break;
329
+ case 'marginHorizontal':
330
+ out.marginLeft = cssValue('marginLeft', value);
331
+ out.marginRight = cssValue('marginRight', value);
332
+ break;
333
+ case 'marginVertical':
334
+ out.marginTop = cssValue('marginTop', value);
335
+ out.marginBottom = cssValue('marginBottom', value);
336
+ break;
337
+ case 'shadowColor':
338
+ shadow = { ...(shadow ?? {}), color: String(value) };
339
+ break;
340
+ case 'shadowOffset': {
341
+ const offset = value as { width?: number; height?: number };
342
+ shadow = { ...(shadow ?? {}), x: offset?.width ?? 0, y: offset?.height ?? 0 };
343
+ break;
344
+ }
345
+ case 'shadowRadius':
346
+ shadow = { ...(shadow ?? {}), blur: Number(value) };
347
+ break;
348
+ case 'shadowOpacity':
349
+ // Folded into the color only when no boxShadow string is present.
350
+ shadow = { ...(shadow ?? {}), opacity: Number(value) };
351
+ break;
352
+ case 'textAlignVertical':
353
+ case 'numberOfLines':
354
+ case 'fontVariant':
355
+ break; // no direct CSS equivalent worth approximating here
356
+ case 'borderWidth':
357
+ out.borderWidth = cssValue(name, value);
358
+ out.borderStyle = 'solid';
359
+ break;
360
+ case 'borderBottomWidth':
361
+ out.borderBottomWidth = cssValue(name, value);
362
+ out.borderBottomStyle = 'solid';
363
+ break;
364
+ case 'borderLeftWidth':
365
+ out.borderLeftWidth = cssValue(name, value);
366
+ out.borderLeftStyle = 'solid';
367
+ break;
368
+ default:
369
+ out[name] = cssValue(name, value);
370
+ break;
371
+ }
372
+ }
373
+
374
+ if (!out.boxShadow && shadow && (shadow.blur ?? 0) > 0 && shadow.color) {
375
+ const opacity = Math.max(0, Math.min(shadow.opacity ?? 1, 1));
376
+ const parsed = parseColor(shadow.color);
377
+ const cssColor = parsed
378
+ ? colorToRgba({ ...parsed, a: Math.round(parsed.a * opacity) })
379
+ : shadow.color;
380
+ out.boxShadow = `${shadow.x ?? 0}px ${shadow.y ?? 0}px ${shadow.blur}px ${cssColor}`;
381
+ }
382
+
383
+ return out;
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Tag mapping + per-tag defaults
388
+ // ---------------------------------------------------------------------------
389
+
390
+ const RN_CONTAINER_TAGS = new Set(['View', 'ScrollView', 'Pressable', 'List']);
391
+ const WEB_CONTAINER_TAGS = new Set([
392
+ 'a',
393
+ 'div',
394
+ 'section',
395
+ 'main',
396
+ 'article',
397
+ 'blockquote',
398
+ 'pre',
399
+ 'hr',
400
+ 'nav',
401
+ 'aside',
402
+ 'header',
403
+ 'footer',
404
+ 'form',
405
+ 'label',
406
+ ]);
407
+ const TEXT_TAGS = new Set(['Text', 'text', 'span', 'p', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
408
+ // Semantic elements whose user-agent stylesheet margins must be zeroed so web
409
+ // layout matches the kernel/Taffy layout (which has no UA stylesheet). `tagName`
410
+ // is uppercase. Heading margins (`h1..h6`) were the missing case behind
411
+ // ENG-22092: web headings inherited `margin-block: <factor>em` from the UA
412
+ // sheet while native had none, so vertical spacing diverged. Surfaces that want
413
+ // heading spacing set it explicitly (the blog uses `headingMarginBlock`).
414
+ const UA_MARGIN_RESET_TAGS = new Set(['BLOCKQUOTE', 'PRE', 'HR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
415
+
416
+ function defaultDomTagFor(node: ElementNode): DomTagName {
417
+ const tag = node.originalTag;
418
+ const props = (node.originalProps ?? {}) as Record<string, unknown>;
419
+ if (tag === 'Pressable' || tag === 'button' || tag === 'a') {
420
+ return 'button';
421
+ }
422
+ if (tag === 'TextInput' || tag === 'input' || tag === 'textarea') {
423
+ return props.multiline ? 'textarea' : 'input';
424
+ }
425
+ if (tag === 'Toggle' || tag === 'Switch' || tag === 'toggle') {
426
+ return 'input';
427
+ }
428
+ if (tag === 'Image' || tag === 'img') {
429
+ if (typeof props.svgSource === 'string' && props.svgSource.trim().length > 0) {
430
+ return 'span';
431
+ }
432
+ return 'img';
433
+ }
434
+ if (TEXT_TAGS.has(tag)) {
435
+ return tag === 'Text' || tag === 'text' || tag === 'p' ? 'span' : (tag as DomTagName);
436
+ }
437
+ if (WEB_CONTAINER_TAGS.has(tag)) {
438
+ return tag as DomTagName;
439
+ }
440
+ return 'div';
441
+ }
442
+
443
+ function semanticAttrsFromProps(props: Record<string, unknown>): HostSemanticAttrs | undefined {
444
+ const tag = props.__exactSemanticTag;
445
+ return typeof tag === 'string' && tag.length > 0 ? { tag } : undefined;
446
+ }
447
+
448
+ function loweredAttrsFor(node: ElementNode) {
449
+ const props = (node.originalProps ?? {}) as Record<string, unknown>;
450
+ return lowerHostAttrs(
451
+ { tag: node.originalTag, props },
452
+ semanticAttrsFromProps(props),
453
+ { defaultTag: defaultDomTagFor(node) },
454
+ );
455
+ }
456
+
457
+ function domTagFor(node: ElementNode): DomTagName {
458
+ return loweredAttrsFor(node).tag;
459
+ }
460
+
461
+ // True when an interactive node sits directly inside a text element, i.e. it is
462
+ // part of an inline run — a markdown link or footnote reference inside a
463
+ // paragraph's `text`. Such a node must flow inline rather than become a
464
+ // block-level flex container. A standalone link/button (e.g. a footnote
465
+ // back-link inside a `row`) has a non-text parent and keeps its flex layout.
466
+ function isInlineTextFlowChild(node: ElementNode): boolean {
467
+ const parent = node.parent;
468
+ if (!parent || parent.kind !== NodeKind.Element) {
469
+ return false;
470
+ }
471
+ return TEXT_TAGS.has((parent as ElementNode).originalTag);
472
+ }
473
+
474
+ function applyTagDefaults(el: HTMLElement, node: ElementNode): void {
475
+ const tag = node.originalTag;
476
+ const style = el.style as unknown as Record<string, string>;
477
+ style.boxSizing = 'border-box';
478
+
479
+ if (RN_CONTAINER_TAGS.has(tag)) {
480
+ style.display = 'flex';
481
+ style.flexDirection = 'column';
482
+ style.alignItems = 'stretch';
483
+ style.position = 'relative';
484
+ style.minWidth = '0';
485
+ style.minHeight = '0';
486
+ // ENG-22199: RN/Yoga (and the kernel/Taffy path via buildDefaultCanonicalStyle
487
+ // for RN-style tags) default flexShrink to 0 — flex items keep their content
488
+ // size and overflow rather than shrinking. CSS flexbox defaults flexShrink to
489
+ // 1, so a bare `row`/`column` inside an overflowing flex column would shrink
490
+ // below its content (down to min-height:0, set just above) and collapse to
491
+ // height 0 while its children paint at full size, overlapping the next
492
+ // sibling. Matching the RN default keeps the mirror's layout consistent with
493
+ // native and with initial render. Explicit `flex`/`shrink`/`grow` styles are
494
+ // applied after these defaults and still win (e.g. the ScrollView flex below).
495
+ style.flexShrink = '0';
496
+ }
497
+ if (tag === 'ScrollView') {
498
+ style.overflowY = 'auto';
499
+ style.flex = '1 1 0%';
500
+ style.width = '100%';
501
+ style.minHeight = '0';
502
+ }
503
+ if (WEB_CONTAINER_TAGS.has(tag)) {
504
+ style.display = 'flex';
505
+ style.flexDirection = 'row';
506
+ style.position = 'relative';
507
+ }
508
+ if (UA_MARGIN_RESET_TAGS.has(el.tagName)) {
509
+ style.margin = '0';
510
+ }
511
+ if (el.tagName === 'PRE') {
512
+ style.whiteSpace = 'pre-wrap';
513
+ style.font = 'inherit';
514
+ }
515
+ if (el.tagName === 'HR') {
516
+ style.border = 'none';
517
+ style.display = 'block';
518
+ }
519
+ if (TEXT_TAGS.has(tag)) {
520
+ style.whiteSpace = 'pre-wrap';
521
+ }
522
+ if (el.tagName === 'BUTTON') {
523
+ el.setAttribute('type', 'button');
524
+ style.display = 'flex';
525
+ style.flexDirection = 'column';
526
+ style.alignItems = 'stretch';
527
+ style.position = 'relative';
528
+ style.background = 'transparent';
529
+ style.border = 'none';
530
+ style.margin = '0';
531
+ style.padding = '0';
532
+ style.font = 'inherit';
533
+ style.color = 'inherit';
534
+ style.textAlign = 'inherit';
535
+ style.cursor = 'pointer';
536
+ }
537
+ if (el.tagName === 'A') {
538
+ style.display = 'flex';
539
+ style.flexDirection = 'column';
540
+ style.alignItems = 'stretch';
541
+ style.position = 'relative';
542
+ style.color = 'inherit';
543
+ style.textDecoration = 'none';
544
+ style.cursor = 'pointer';
545
+ }
546
+ if (tag === 'Toggle' || tag === 'Switch' || tag === 'toggle') {
547
+ el.setAttribute('type', 'checkbox');
548
+ }
549
+ if ((el.tagName === 'INPUT' && el.getAttribute('type') !== 'checkbox') || el.tagName === 'TEXTAREA') {
550
+ // Form controls do not inherit typography from their parents in the UA
551
+ // stylesheet; without this an unstyled input renders UA-black text on
552
+ // themed dark surfaces (Design Mode panel inputs). Explicit color/font
553
+ // styles still win — node styles are applied after tag defaults.
554
+ style.font = 'inherit';
555
+ style.color = 'inherit';
556
+ }
557
+
558
+ // ENG-22115: An interactive node (a link or button) that sits inside a text
559
+ // element is part of an inline run — e.g. a markdown link inside a paragraph.
560
+ // The flex-container defaults above (display:flex; flex-direction:column)
561
+ // make it a block-level box, so on web the link broke onto its own line with
562
+ // weird breaks before and after it, while macOS rendered it inline. Native
563
+ // flattens text + link children into a single inline run via static-text
564
+ // composition; the web analog is to let the element flow inline. Applied
565
+ // last so it overrides the BUTTON/A/Pressable flex defaults set above, and
566
+ // node styles (applied after tag defaults) can still override it.
567
+ if ((el.tagName === 'A' || el.tagName === 'BUTTON') && isInlineTextFlowChild(node)) {
568
+ style.display = 'inline';
569
+ style.flexDirection = '';
570
+ style.alignItems = '';
571
+ style.minWidth = '';
572
+ style.minHeight = '';
573
+ style.flexShrink = '';
574
+ }
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Props -> attributes
579
+ // ---------------------------------------------------------------------------
580
+
581
+ function isMirrorDevMode(): boolean {
582
+ const runtime = globalThis as typeof globalThis & {
583
+ __DEV__?: boolean;
584
+ process?: { env?: { NODE_ENV?: string } };
585
+ };
586
+ if (typeof runtime.__DEV__ === 'boolean') {
587
+ return runtime.__DEV__;
588
+ }
589
+ return runtime.process?.env?.NODE_ENV !== 'production';
590
+ }
591
+
592
+ function clearElementAttributes(el: HTMLElement): void {
593
+ for (const name of el.getAttributeNames()) {
594
+ el.removeAttribute(name);
595
+ }
596
+ }
597
+
598
+ function shouldApplyHostAttr(name: string, value: unknown): boolean {
599
+ if (name === 'style' || name === 'children' || name === 'ref' || name === 'key') {
600
+ return false;
601
+ }
602
+ if (value === undefined || value === null) {
603
+ return false;
604
+ }
605
+ if (typeof value === 'function') {
606
+ return false;
607
+ }
608
+ if (typeof value === 'object') {
609
+ return false;
610
+ }
611
+ if (!isSafeHostDomAttr(name, value)) {
612
+ return false;
613
+ }
614
+ return true;
615
+ }
616
+
617
+ function applyHostAttr(el: HTMLElement, name: string, value: unknown): void {
618
+ if (!shouldApplyHostAttr(name, value)) {
619
+ return;
620
+ }
621
+ if (name === 'className') {
622
+ el.setAttribute('class', String(value));
623
+ return;
624
+ }
625
+ if (name === 'htmlFor') {
626
+ el.setAttribute('for', String(value));
627
+ return;
628
+ }
629
+ if (name === 'tabIndex') {
630
+ el.tabIndex = Number(value);
631
+ return;
632
+ }
633
+ if (typeof value === 'boolean') {
634
+ if (name.startsWith('aria-')) {
635
+ el.setAttribute(name, String(value));
636
+ return;
637
+ }
638
+ if (value === false) {
639
+ return;
640
+ }
641
+ if (name === 'inert') {
642
+ (el as HTMLElement & { inert?: boolean }).inert = true;
643
+ }
644
+ el.setAttribute(name, '');
645
+ return;
646
+ }
647
+ el.setAttribute(name, String(value));
648
+ }
649
+
650
+ let nextInlineSvgTitleId = 1;
651
+
652
+ function svgParserConstructor(): typeof DOMParser | undefined {
653
+ const runtimeParser = (globalThis as typeof globalThis & { DOMParser?: typeof DOMParser }).DOMParser;
654
+ if (runtimeParser) {
655
+ return runtimeParser;
656
+ }
657
+ return (globalThis as typeof globalThis & { window?: { DOMParser?: typeof DOMParser } }).window?.DOMParser;
658
+ }
659
+
660
+ function parseInlineSvg(markup: string): SVGSVGElement | null {
661
+ const template = document.createElement('template');
662
+ template.innerHTML = markup.trim();
663
+ const templateSvg = template.content.firstElementChild;
664
+ if (templateSvg && templateSvg.tagName.toLowerCase() === 'svg') {
665
+ return document.adoptNode(templateSvg) as unknown as SVGSVGElement;
666
+ }
667
+
668
+ const Parser = svgParserConstructor();
669
+ if (Parser) {
670
+ const parsed = new Parser().parseFromString(markup, 'image/svg+xml');
671
+ if (parsed.querySelector('parsererror')) {
672
+ return null;
673
+ }
674
+ const svg = parsed.documentElement;
675
+ if (!svg || svg.tagName.toLowerCase() !== 'svg') {
676
+ return null;
677
+ }
678
+ return document.adoptNode(svg) as unknown as SVGSVGElement;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ function mapSvgObjectPosition(objectPosition: unknown): string {
684
+ if (typeof objectPosition !== 'string' || objectPosition.trim().length === 0) {
685
+ return 'xMidYMid';
686
+ }
687
+
688
+ const tokens = objectPosition.toLowerCase().trim().split(/\s+/).filter(Boolean);
689
+ let horizontal: 'xMin' | 'xMid' | 'xMax' = 'xMid';
690
+ let vertical: 'YMin' | 'YMid' | 'YMax' = 'YMid';
691
+
692
+ for (const token of tokens) {
693
+ if (token === 'left') {
694
+ horizontal = 'xMin';
695
+ } else if (token === 'right') {
696
+ horizontal = 'xMax';
697
+ } else if (token === 'top') {
698
+ vertical = 'YMin';
699
+ } else if (token === 'bottom') {
700
+ vertical = 'YMax';
701
+ } else if (token === 'center') {
702
+ if (!tokens.some((value) => value === 'left' || value === 'right')) {
703
+ horizontal = 'xMid';
704
+ }
705
+ if (!tokens.some((value) => value === 'top' || value === 'bottom')) {
706
+ vertical = 'YMid';
707
+ }
708
+ }
709
+ }
710
+
711
+ return `${horizontal}${vertical}`;
712
+ }
713
+
714
+ function applyInlineSvgSizing(svg: SVGSVGElement, props: Record<string, unknown>): void {
715
+ const fit = props.objectFit ?? 'contain';
716
+ const alignment = mapSvgObjectPosition(props.objectPosition);
717
+ if (fit === 'cover') {
718
+ svg.setAttribute('preserveAspectRatio', `${alignment} slice`);
719
+ } else if (fit === 'fill') {
720
+ svg.setAttribute('preserveAspectRatio', 'none');
721
+ } else if (fit === 'none') {
722
+ svg.setAttribute('preserveAspectRatio', alignment);
723
+ } else {
724
+ svg.setAttribute('preserveAspectRatio', `${alignment} meet`);
725
+ }
726
+ }
727
+
728
+ function applyInlineSvgTint(svg: SVGSVGElement, tintColor: unknown): void {
729
+ if (typeof tintColor !== 'string' || tintColor.length === 0) {
730
+ return;
731
+ }
732
+
733
+ svg.style.color = tintColor;
734
+ const paintedNodes = svg.querySelectorAll<SVGElement>('[fill], [stroke]');
735
+ paintedNodes.forEach((node) => {
736
+ const fill = node.getAttribute('fill');
737
+ if (fill && fill !== 'none' && !fill.startsWith('url(')) {
738
+ node.setAttribute('fill', 'currentColor');
739
+ }
740
+
741
+ const stroke = node.getAttribute('stroke');
742
+ if (stroke && stroke !== 'none' && !stroke.startsWith('url(')) {
743
+ node.setAttribute('stroke', 'currentColor');
744
+ }
745
+ });
746
+ }
747
+
748
+ function applyInlineSvgAccessibility(svg: SVGSVGElement, props: Record<string, unknown>): void {
749
+ if (props.decorative === true) {
750
+ svg.setAttribute('aria-hidden', 'true');
751
+ return;
752
+ }
753
+
754
+ const label = typeof props.alt === 'string' && props.alt.length > 0
755
+ ? props.alt
756
+ : typeof props.accessibilityLabel === 'string' && props.accessibilityLabel.length > 0
757
+ ? props.accessibilityLabel
758
+ : undefined;
759
+ if (!label) {
760
+ return;
761
+ }
762
+
763
+ svg.setAttribute('role', 'img');
764
+ let title = svg.querySelector<SVGElement>('title');
765
+ if (!title) {
766
+ title = document.createElementNS('http://www.w3.org/2000/svg', 'title') as SVGElement;
767
+ svg.prepend(title);
768
+ }
769
+ title.textContent = label;
770
+ const titleId = `exact-svg-title-${nextInlineSvgTitleId++}`;
771
+ title.setAttribute('id', titleId);
772
+ svg.setAttribute('aria-labelledby', titleId);
773
+ }
774
+
775
+ function applyInlineSvgHost(el: HTMLElement, props: Record<string, unknown>): void {
776
+ const markup = typeof props.svgSource === 'string' ? props.svgSource : undefined;
777
+ if (!markup || markup.trim().length === 0) {
778
+ while (el.firstChild) {
779
+ el.removeChild(el.firstChild);
780
+ }
781
+ return;
782
+ }
783
+
784
+ const style = el.style as unknown as Record<string, string>;
785
+ style.display = 'block';
786
+ style.boxSizing = 'border-box';
787
+ style.overflow = 'hidden';
788
+
789
+ while (el.firstChild) {
790
+ el.removeChild(el.firstChild);
791
+ }
792
+
793
+ const svg = parseInlineSvg(markup);
794
+ if (!svg) {
795
+ return;
796
+ }
797
+
798
+ svg.style.display = 'block';
799
+ svg.style.width = '100%';
800
+ svg.style.height = '100%';
801
+ applyInlineSvgSizing(svg, props);
802
+ applyInlineSvgTint(svg, props.tintColor);
803
+ applyInlineSvgAccessibility(svg, props);
804
+ el.appendChild(svg);
805
+ }
806
+
807
+ function applyProps(el: HTMLElement, node: ElementNode, runtime: DomHostRuntimeOptions): void {
808
+ const props = (node.originalProps ?? {}) as Record<string, unknown>;
809
+ const lowered = loweredAttrsFor(node);
810
+
811
+ // Design Mode D0 (LLP 0158): dev-only DOM → host-node identity so the
812
+ // overlay can hit-test real pixels back to renderer nodes.
813
+ clearElementAttributes(el);
814
+ if (runtime.exposeDevIdentity && isMirrorDevMode()) {
815
+ el.setAttribute('data-exact-view-id', String(node.id));
816
+ }
817
+
818
+ // Reset previous inline styles, then defaults + converted style.
819
+ applyTagDefaults(el, node);
820
+ const css = convertStyle(props.style as Record<string, unknown> | undefined);
821
+ const style = el.style as unknown as Record<string, string>;
822
+ for (const [name, value] of Object.entries(css)) {
823
+ style[name] = value;
824
+ }
825
+ applyTextLineClampStyles(el, node, props);
826
+
827
+ for (const [name, value] of Object.entries(lowered.attrs)) {
828
+ applyHostAttr(el, name, value);
829
+ }
830
+
831
+ if (props.focusScope === 'trapped') {
832
+ el.setAttribute('data-exact-focus-scope', 'trapped');
833
+ if (props.__exactFocusRestore === true) {
834
+ el.setAttribute('data-exact-focus-restore', 'true');
835
+ }
836
+ if (!el.hasAttribute('tabindex')) {
837
+ el.tabIndex = -1;
838
+ }
839
+ }
840
+
841
+ if (el.tagName === 'BUTTON' || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
842
+ (el as HTMLButtonElement).disabled = props.disabled === true;
843
+ }
844
+
845
+ if (el.tagName === 'INPUT' && el.getAttribute('type') === 'checkbox') {
846
+ (el as HTMLInputElement).checked = props.value === true || props.checked === true;
847
+ } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
848
+ const input = el as HTMLInputElement;
849
+ if (props.placeholder != null) {
850
+ input.setAttribute('placeholder', String(props.placeholder));
851
+ }
852
+ if (props.secureTextEntry === true) {
853
+ input.setAttribute('type', 'password');
854
+ }
855
+ const value = props.value;
856
+ if (value !== undefined && input.value !== String(value ?? '')) {
857
+ input.value = String(value ?? '');
858
+ }
859
+ }
860
+
861
+ if (el.tagName === 'IMG') {
862
+ const img = el as HTMLImageElement;
863
+ applyThemeImageProps(img, props);
864
+ const resolvedSrc = resolveThemeImageSrc(props);
865
+ if (resolvedSrc != null && isSafeHostDomAttr('src', resolvedSrc)) {
866
+ img.setAttribute('src', resolvedSrc);
867
+ } else if (props.src != null && isSafeHostDomAttr('src', props.src)) {
868
+ img.setAttribute('src', String(props.src));
869
+ }
870
+ if (props.decorative === true) {
871
+ img.setAttribute('alt', '');
872
+ } else if (props.alt != null) {
873
+ img.setAttribute('alt', String(props.alt));
874
+ } else if (props.accessibilityLabel != null) {
875
+ img.setAttribute('alt', String(props.accessibilityLabel));
876
+ }
877
+ } else if (el.tagName === 'SPAN' && typeof props.svgSource === 'string') {
878
+ applyInlineSvgHost(el, props);
879
+ }
880
+ }
881
+
882
+ function applyThemeImageProps(img: HTMLImageElement, props: Record<string, unknown>): void {
883
+ const lightSrc = stringProp(props.lightSrc);
884
+ const darkSrc = stringProp(props.darkSrc);
885
+ const rawTreatment = stringProp(props.themeTreatment);
886
+ const treatment = rawTreatment === 'none' ? undefined : rawTreatment;
887
+
888
+ setOrRemoveAttr(img, 'data-light-src', lightSrc);
889
+ setOrRemoveAttr(img, 'data-dark-src', darkSrc);
890
+ setOrRemoveAttr(img, 'data-exact-theme-treatment', treatment);
891
+ if (lightSrc || darkSrc || treatment) {
892
+ img.setAttribute('data-exact-theme-image', 'color-scheme');
893
+ } else {
894
+ img.removeAttribute('data-exact-theme-image');
895
+ }
896
+ }
897
+
898
+ function resolveThemeImageSrc(props: Record<string, unknown>): string | undefined {
899
+ const src = stringProp(props.src);
900
+ const lightSrc = stringProp(props.lightSrc);
901
+ const darkSrc = stringProp(props.darkSrc);
902
+ if (!lightSrc && !darkSrc) {
903
+ return src;
904
+ }
905
+
906
+ const scheme = getRuntimeImageColorScheme();
907
+ return scheme === 'dark'
908
+ ? darkSrc ?? src ?? lightSrc
909
+ : lightSrc ?? src ?? darkSrc;
910
+ }
911
+
912
+ function stringProp(value: unknown): string | undefined {
913
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
914
+ }
915
+
916
+ function setOrRemoveAttr(el: HTMLElement, name: string, value: string | undefined): void {
917
+ if (value != null) {
918
+ el.setAttribute(name, value);
919
+ } else {
920
+ el.removeAttribute(name);
921
+ }
922
+ }
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Focus scopes: Contract overlay metadata -> real browser focus behavior
926
+ // ---------------------------------------------------------------------------
927
+
928
+ const FOCUSABLE_SELECTOR = [
929
+ 'a[href]',
930
+ 'button:not([disabled])',
931
+ 'input:not([disabled]):not([type="hidden"])',
932
+ 'select:not([disabled])',
933
+ 'textarea:not([disabled])',
934
+ '[tabindex]:not([tabindex="-1"])',
935
+ ].join(',');
936
+
937
+ function activeElement(): HTMLElement | null {
938
+ const active = document.activeElement;
939
+ return active && typeof (active as HTMLElement).focus === 'function'
940
+ ? (active as HTMLElement)
941
+ : null;
942
+ }
943
+
944
+ function focusableDescendants(scope: HTMLElement): HTMLElement[] {
945
+ const nodes = scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
946
+ const focusables: HTMLElement[] = [];
947
+ for (let index = 0; index < nodes.length; index += 1) {
948
+ const candidate = nodes.item(index);
949
+ if (
950
+ !candidate.hasAttribute('disabled') &&
951
+ candidate.getAttribute('aria-hidden') !== 'true' &&
952
+ candidate.tabIndex >= 0
953
+ ) {
954
+ focusables.push(candidate);
955
+ }
956
+ }
957
+ return focusables;
958
+ }
959
+
960
+ function focusTargetForScope(scope: HTMLElement): HTMLElement {
961
+ return focusableDescendants(scope)[0] ?? scope;
962
+ }
963
+
964
+ function trapTabKey(scope: HTMLElement, event: KeyboardEvent): void {
965
+ const focusables = focusableDescendants(scope);
966
+ if (focusables.length === 0) {
967
+ event.preventDefault();
968
+ scope.focus();
969
+ return;
970
+ }
971
+
972
+ const active = activeElement();
973
+ const index = active ? focusables.indexOf(active) : -1;
974
+ if (event.shiftKey) {
975
+ if (index <= 0) {
976
+ event.preventDefault();
977
+ focusables[focusables.length - 1]?.focus();
978
+ }
979
+ return;
980
+ }
981
+
982
+ if (index === -1 || index === focusables.length - 1) {
983
+ event.preventDefault();
984
+ focusables[0]?.focus();
985
+ }
986
+ }
987
+
988
+ // ---------------------------------------------------------------------------
989
+ // Events: DOM -> adapter handler props (read lazily at event time, so
990
+ // updated handlers and interaction behaviors keep working without rewiring)
991
+ // ---------------------------------------------------------------------------
992
+
993
+ function handler(node: ElementNode, name: string): ((...args: unknown[]) => void) | null {
994
+ const props = (node.originalProps ?? {}) as Record<string, unknown>;
995
+ const candidate = props[name];
996
+ return typeof candidate === 'function' ? (candidate as (...args: unknown[]) => void) : null;
997
+ }
998
+
999
+ function syncControlledInputValue(el: HTMLInputElement | HTMLTextAreaElement, node: ElementNode): void {
1000
+ const props = (node.originalProps ?? {}) as Record<string, unknown>;
1001
+ const value = props.value;
1002
+ if (value === undefined) {
1003
+ return;
1004
+ }
1005
+ const controlledValue = String(value ?? '');
1006
+ if (el.value !== controlledValue) {
1007
+ el.value = controlledValue;
1008
+ }
1009
+ }
1010
+
1011
+ // LLP 0281 stage 0 (LLP 0275 A1): element-local pointer payload for the
1012
+ // Contract pointer event attrs. The envelope carries the pinned sample
1013
+ // (element-local coordinates) plus the element box so a rated publish can
1014
+ // normalize to fractions without a second geometry read.
1015
+ function pointerEnvelope(el: HTMLElement, event: Event, phase: string): Record<string, unknown> {
1016
+ const pointer = event as PointerEvent;
1017
+ const rect = el.getBoundingClientRect();
1018
+ return {
1019
+ sample: {
1020
+ x: (pointer.clientX ?? 0) - rect.left,
1021
+ y: (pointer.clientY ?? 0) - rect.top,
1022
+ pointerId: pointer.pointerId ?? -1,
1023
+ pointerType: pointer.pointerType || 'mouse',
1024
+ buttons: pointer.buttons ?? 0,
1025
+ pressure: pointer.pressure ?? 0,
1026
+ },
1027
+ element: { width: rect.width, height: rect.height },
1028
+ phase,
1029
+ timeStampMs: event.timeStamp,
1030
+ };
1031
+ }
1032
+
1033
+ function dispatchPointerHandler(
1034
+ el: HTMLElement,
1035
+ node: ElementNode,
1036
+ event: Event,
1037
+ phase: string,
1038
+ handlerProp: string,
1039
+ ): void {
1040
+ const pointerHandler = handler(node, handlerProp);
1041
+ if (pointerHandler) {
1042
+ pointerHandler(pointerEnvelope(el, event, phase));
1043
+ }
1044
+ }
1045
+
1046
+ function wireEvents(el: HTMLElement, node: ElementNode): void {
1047
+ el.addEventListener('click', (event) => {
1048
+ if ((node.originalProps as Record<string, unknown>)?.disabled === true) {
1049
+ return;
1050
+ }
1051
+ handler(node, 'onPress')?.({ type: 'press', nativeEvent: event });
1052
+ });
1053
+ el.addEventListener('pointerdown', (event) => {
1054
+ handler(node, 'onPressIn')?.({ type: 'pressin' });
1055
+ // The capture affordance (LLP 0275 A2): explicit `pointerCapture=true`
1056
+ // captures the pointer on down, web-platform style, so a drag keeps
1057
+ // streaming to this element after the pointer leaves its box.
1058
+ const props = (node.originalProps ?? {}) as Record<string, unknown>;
1059
+ if (props.pointerCapture === true) {
1060
+ const pointerId = (event as PointerEvent).pointerId;
1061
+ if (typeof pointerId === 'number' && typeof el.setPointerCapture === 'function') {
1062
+ try {
1063
+ el.setPointerCapture(pointerId);
1064
+ } catch {
1065
+ // Capture is best-effort (the pointer may already be gone).
1066
+ }
1067
+ }
1068
+ }
1069
+ dispatchPointerHandler(el, node, event, 'pointerdown', 'onPointerDown');
1070
+ });
1071
+ el.addEventListener('pointerup', (event) => {
1072
+ handler(node, 'onPressOut')?.({ type: 'pressout' });
1073
+ dispatchPointerHandler(el, node, event, 'pointerup', 'onPointerUp');
1074
+ });
1075
+ el.addEventListener('pointermove', (event) => {
1076
+ dispatchPointerHandler(el, node, event, 'pointermove', 'onPointerMove');
1077
+ });
1078
+ el.addEventListener('pointercancel', (event) => {
1079
+ dispatchPointerHandler(el, node, event, 'pointercancel', 'onPointerCancel');
1080
+ });
1081
+ el.addEventListener('pointerenter', (event) => {
1082
+ handler(node, 'onHoverIn')?.({ type: 'hoverin' });
1083
+ handler(node, 'onMouseEnter')?.({ type: 'mouseenter' });
1084
+ dispatchPointerHandler(el, node, event, 'pointerenter', 'onPointerEnter');
1085
+ });
1086
+ el.addEventListener('pointerleave', (event) => {
1087
+ handler(node, 'onPressOut')?.({ type: 'pressout' });
1088
+ handler(node, 'onHoverOut')?.({ type: 'hoverout' });
1089
+ handler(node, 'onMouseLeave')?.({ type: 'mouseleave' });
1090
+ dispatchPointerHandler(el, node, event, 'pointerleave', 'onPointerLeave');
1091
+ });
1092
+ el.addEventListener('focus', () => {
1093
+ handler(node, 'onFocus')?.({ type: 'focus' });
1094
+ });
1095
+ el.addEventListener('blur', () => {
1096
+ handler(node, 'onBlur')?.({ type: 'blur' });
1097
+ });
1098
+ el.addEventListener('keydown', (event) => {
1099
+ if ((node.originalProps as Record<string, unknown>)?.focusScope === 'trapped') {
1100
+ const key = (event as KeyboardEvent).key;
1101
+ if (key === 'Tab') {
1102
+ trapTabKey(el, event as KeyboardEvent);
1103
+ }
1104
+ }
1105
+ handler(node, 'onKeyDown')?.({ key: (event as KeyboardEvent).key });
1106
+ });
1107
+
1108
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
1109
+ el.addEventListener('input', () => {
1110
+ const input = el as HTMLInputElement;
1111
+ if (input.getAttribute('type') === 'checkbox') {
1112
+ return;
1113
+ }
1114
+ handler(node, 'onChangeText')?.(input.value);
1115
+ handler(node, 'onChange')?.({ value: input.value, nativeEvent: { text: input.value } });
1116
+ syncControlledInputValue(input, node);
1117
+ });
1118
+ el.addEventListener('change', () => {
1119
+ const input = el as HTMLInputElement;
1120
+ if (input.getAttribute('type') !== 'checkbox') {
1121
+ return;
1122
+ }
1123
+ handler(node, 'onChange')?.({ value: input.checked, nativeEvent: { value: input.checked } });
1124
+ handler(node, 'onValueChange')?.(input.checked);
1125
+ });
1126
+ }
1127
+ }
1128
+
1129
+ // ---------------------------------------------------------------------------
1130
+ // Reconciliation
1131
+ // ---------------------------------------------------------------------------
1132
+
1133
+ const MIRRORED = Symbol('exactDomMirror');
1134
+
1135
+ interface MirroredDomNode extends Node {
1136
+ [MIRRORED]?: true;
1137
+ }
1138
+
1139
+ function installDomHost(
1140
+ options: DomMirrorOptions,
1141
+ runtime: DomHostRuntimeOptions,
1142
+ ): DomMirrorHandle | null {
1143
+ if (typeof document === 'undefined') {
1144
+ return null;
1145
+ }
1146
+ if (hasProtocolHost() && options.force !== true) {
1147
+ // A real platform host owns pixels; the mirror stays out of the way.
1148
+ return null;
1149
+ }
1150
+
1151
+ const domByNode = new WeakMap<HostChild, MirroredDomNode>();
1152
+ const domByViewId = new Map<number, HTMLElement>();
1153
+ const viewIdByDom = new WeakMap<Node, number>();
1154
+ const appliedProps = new WeakMap<ElementNode, unknown>();
1155
+ const appliedText = new WeakMap<TextNode, string>();
1156
+ // LLP 0281 §3.3 — channel-driven style values (transform/opacity sinks).
1157
+ // Applied straight to the DOM node, bypassing the reactive flush; kept
1158
+ // here so applyProps (which resets inline styles) re-applies them after
1159
+ // any ordinary prop update.
1160
+ const channelStyles = new WeakMap<ElementNode, Record<string, unknown>>();
1161
+ const rootContainers = new Map<number, HTMLElement>();
1162
+ const wiredDomNodes = new WeakSet<HTMLElement>();
1163
+ const focusScopes = new Map<HTMLElement, { previous: HTMLElement | null; restore: boolean }>();
1164
+ const hydrationAdopter = options.hydrate
1165
+ ? globalThis.__exactDomHydrationAdopterFactory?.({
1166
+ hydrate: options.hydrate,
1167
+ markMirrored(node) {
1168
+ (node as MirroredDomNode)[MIRRORED] = true;
1169
+ },
1170
+ }) ?? null
1171
+ : null;
1172
+ let disposed = false;
1173
+
1174
+ const baseContainer = (): HTMLElement =>
1175
+ options.container ?? (document.getElementById('exact-root') as HTMLElement | null) ?? document.body;
1176
+
1177
+ const previousIdentity = globalThis.__exactDomIdentity;
1178
+ const identityRegistry: DomIdentityRegistry = {
1179
+ elementForViewId(viewId: number): HTMLElement | null {
1180
+ const element = domByViewId.get(viewId) ?? null;
1181
+ if (element?.isConnected) {
1182
+ return element;
1183
+ }
1184
+ return previousIdentity?.elementForViewId(viewId) ?? null;
1185
+ },
1186
+ viewIdForTarget(target: EventTarget | null): number | null {
1187
+ let node = asDomNode(target);
1188
+ while (node) {
1189
+ const id = viewIdByDom.get(node);
1190
+ if (typeof id === 'number') {
1191
+ return id;
1192
+ }
1193
+ node = node.parentNode;
1194
+ }
1195
+ return previousIdentity?.viewIdForTarget(target) ?? null;
1196
+ },
1197
+ };
1198
+ globalThis.__exactDomIdentity = identityRegistry;
1199
+
1200
+ function containerFor(rootId: number): HTMLElement {
1201
+ let container = rootContainers.get(rootId);
1202
+ if (!container || !container.isConnected) {
1203
+ const adopted = hydrationAdopter?.adoptRootContainer(
1204
+ rootId,
1205
+ baseContainer(),
1206
+ runtime.rootAttribute,
1207
+ );
1208
+ if (adopted) {
1209
+ (adopted as MirroredDomNode)[MIRRORED] = true;
1210
+ rootContainers.set(rootId, adopted);
1211
+ return adopted;
1212
+ }
1213
+ container = document.createElement('div');
1214
+ (container as MirroredDomNode)[MIRRORED] = true;
1215
+ container.setAttribute(runtime.rootAttribute, String(rootId));
1216
+ const style = container.style as unknown as Record<string, string>;
1217
+ style.display = 'flex';
1218
+ style.flexDirection = 'column';
1219
+ style.alignItems = 'stretch';
1220
+ style.width = '100%';
1221
+ style.height = '100%';
1222
+ style.minHeight = '0';
1223
+ // Platform parity: native renders unstyled text in the system font, so
1224
+ // the mirror's default must be the system sans stack — not the
1225
+ // browser's serif fallback. Nodes that set fontFamily still win.
1226
+ style.fontFamily =
1227
+ "-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, 'Helvetica Neue', sans-serif";
1228
+ baseContainer().appendChild(container);
1229
+ rootContainers.set(rootId, container);
1230
+ }
1231
+ return container;
1232
+ }
1233
+
1234
+ function syncFocusScopes(rootContainer: HTMLElement): void {
1235
+ const currentNodes = rootContainer.querySelectorAll<HTMLElement>('[data-exact-focus-scope="trapped"]');
1236
+ const current = new Set<HTMLElement>();
1237
+ for (let index = 0; index < currentNodes.length; index += 1) {
1238
+ current.add(currentNodes.item(index));
1239
+ }
1240
+
1241
+ for (const [scope, state] of focusScopes) {
1242
+ if (scope.isConnected && current.has(scope)) {
1243
+ continue;
1244
+ }
1245
+ focusScopes.delete(scope);
1246
+ if (state.restore && state.previous?.isConnected) {
1247
+ state.previous.focus();
1248
+ }
1249
+ }
1250
+
1251
+ for (const scope of current) {
1252
+ if (focusScopes.has(scope)) {
1253
+ continue;
1254
+ }
1255
+ const previous = activeElement();
1256
+ focusScopes.set(scope, {
1257
+ previous: previous && !scope.contains(previous) ? previous : null,
1258
+ restore: scope.getAttribute('data-exact-focus-restore') === 'true',
1259
+ });
1260
+ focusTargetForScope(scope).focus();
1261
+ }
1262
+ }
1263
+
1264
+ function wireEventsOnce(el: HTMLElement, node: ElementNode): void {
1265
+ if (wiredDomNodes.has(el)) {
1266
+ return;
1267
+ }
1268
+ wiredDomNodes.add(el);
1269
+ wireEvents(el, node);
1270
+ }
1271
+
1272
+ // LLP 0281 §3.3 — write one channel-driven style value onto the DOM node
1273
+ // (compositor-only work: transform/opacity), through the same style
1274
+ // conversion applyProps uses so values mean the same thing on both paths.
1275
+ function writeChannelStyle(el: HTMLElement, styleProp: string, value: unknown): void {
1276
+ const css = convertStyle({ [styleProp]: value });
1277
+ const style = el.style as unknown as Record<string, string>;
1278
+ for (const [name, cssValue] of Object.entries(css)) {
1279
+ style[name] = cssValue;
1280
+ }
1281
+ }
1282
+
1283
+ function reapplyChannelStyles(el: HTMLElement, node: ElementNode): void {
1284
+ const pending = channelStyles.get(node);
1285
+ if (!pending) {
1286
+ return;
1287
+ }
1288
+ for (const [styleProp, value] of Object.entries(pending)) {
1289
+ writeChannelStyle(el, styleProp, value);
1290
+ }
1291
+ }
1292
+
1293
+ // The host channel-application seam (LLP 0281 stage 0): the Contract
1294
+ // runtime routes channel style sinks here so per-frame pointer motion
1295
+ // never runs a reactive flush or a host-ops prop diff. Values are
1296
+ // recorded per node and re-applied after ordinary prop updates (which
1297
+ // reset inline styles).
1298
+ const channelApplier: HostChannelStyleApplier = (target, styleProp, value) => {
1299
+ const node = target as ElementNode;
1300
+ if (!node || typeof node !== 'object' || node.kind !== NodeKind.Element) {
1301
+ return false;
1302
+ }
1303
+ let pending = channelStyles.get(node);
1304
+ if (!pending) {
1305
+ pending = {};
1306
+ channelStyles.set(node, pending);
1307
+ }
1308
+ pending[styleProp] = value;
1309
+ const dom = domByNode.get(node) as HTMLElement | undefined;
1310
+ if (dom) {
1311
+ writeChannelStyle(dom, styleProp, value);
1312
+ }
1313
+ // Recorded even when the node has no DOM yet — ensureDom applies it on
1314
+ // first sync, so early channel writes are never lost.
1315
+ return true;
1316
+ };
1317
+ const previousChannelApplier = getHostChannelStyleApplier();
1318
+ setHostChannelStyleApplier(channelApplier);
1319
+
1320
+ function ensureDom(
1321
+ child: HostChild,
1322
+ rootId: number,
1323
+ rootContainer: HTMLElement,
1324
+ ): MirroredDomNode {
1325
+ if (child.kind === NodeKind.Text) {
1326
+ const textNode = child as TextNode;
1327
+ let dom = domByNode.get(textNode);
1328
+ if (!dom) {
1329
+ dom = (hydrationAdopter?.adoptDom(textNode, rootId, rootContainer) as MirroredDomNode | null) ??
1330
+ (document.createTextNode(textNode.text) as MirroredDomNode);
1331
+ dom[MIRRORED] = true;
1332
+ domByNode.set(textNode, dom);
1333
+ appliedText.set(textNode, textNode.text);
1334
+ } else if (appliedText.get(textNode) !== textNode.text) {
1335
+ dom.nodeValue = textNode.text;
1336
+ appliedText.set(textNode, textNode.text);
1337
+ }
1338
+ return dom;
1339
+ }
1340
+
1341
+ const element = child as ElementNode;
1342
+ let dom = domByNode.get(element) as (HTMLElement & MirroredDomNode) | undefined;
1343
+ const desiredTag = domTagFor(element);
1344
+ if (!dom || dom.localName !== desiredTag) {
1345
+ const previous = dom;
1346
+ dom = (hydrationAdopter?.adoptDom(
1347
+ element,
1348
+ rootId,
1349
+ rootContainer,
1350
+ desiredTag,
1351
+ ) as HTMLElement & MirroredDomNode | null) ??
1352
+ (document.createElement(desiredTag) as HTMLElement & MirroredDomNode);
1353
+ dom[MIRRORED] = true;
1354
+ domByNode.set(element, dom);
1355
+ wireEventsOnce(dom, element);
1356
+ if (previous?.parentNode) {
1357
+ previous.parentNode.replaceChild(dom, previous);
1358
+ }
1359
+ applyProps(dom, element, runtime);
1360
+ appliedProps.set(element, element.originalProps);
1361
+ reapplyChannelStyles(dom, element);
1362
+ } else if (appliedProps.get(element) !== element.originalProps) {
1363
+ applyProps(dom, element, runtime);
1364
+ appliedProps.set(element, element.originalProps);
1365
+ reapplyChannelStyles(dom, element);
1366
+ }
1367
+ domByViewId.set(element.id, dom);
1368
+ viewIdByDom.set(dom, element.id);
1369
+
1370
+ reconcileChildren(element.children as HostChild[], dom, rootId, rootContainer);
1371
+ return dom;
1372
+ }
1373
+
1374
+ function reconcileChildren(
1375
+ children: HostChild[],
1376
+ domParent: HTMLElement,
1377
+ rootId: number,
1378
+ rootContainer: HTMLElement,
1379
+ ): void {
1380
+ const desired = children.map((child) => ensureDom(child, rootId, rootContainer));
1381
+ const desiredSet = new Set<Node>(desired);
1382
+
1383
+ // Remove mirror-owned DOM children that no longer correspond to a node.
1384
+ for (const existing of Array.from(domParent.childNodes)) {
1385
+ if ((existing as MirroredDomNode)[MIRRORED] && !desiredSet.has(existing)) {
1386
+ domParent.removeChild(existing);
1387
+ }
1388
+ }
1389
+
1390
+ // Put the survivors in order with minimal moves.
1391
+ let cursor = domParent.firstChild;
1392
+ for (const next of desired) {
1393
+ while (cursor && !(cursor as MirroredDomNode)[MIRRORED]) {
1394
+ cursor = cursor.nextSibling;
1395
+ }
1396
+ if (cursor === next) {
1397
+ cursor = cursor.nextSibling;
1398
+ continue;
1399
+ }
1400
+ domParent.insertBefore(next, cursor);
1401
+ }
1402
+ }
1403
+
1404
+ const registry: DomMirrorRegistry = {
1405
+ sync(root: RootNode): void {
1406
+ if (disposed) {
1407
+ return;
1408
+ }
1409
+ const rootContainer = containerFor(root.rootId);
1410
+ reconcileChildren(root.children as HostChild[], rootContainer, root.rootId, rootContainer);
1411
+ hydrationAdopter?.report(root.rootId);
1412
+ syncFocusScopes(rootContainer);
1413
+ },
1414
+ remove(rootId: number): void {
1415
+ const container = rootContainers.get(rootId);
1416
+ container?.parentNode?.removeChild(container);
1417
+ rootContainers.delete(rootId);
1418
+ syncFocusScopes(document.body);
1419
+ },
1420
+ };
1421
+
1422
+ globalThis.__exactDomMirror = registry;
1423
+
1424
+ return {
1425
+ dispose(): void {
1426
+ disposed = true;
1427
+ if (globalThis.__exactDomMirror === registry) {
1428
+ globalThis.__exactDomMirror = undefined;
1429
+ }
1430
+ if (globalThis.__exactDomIdentity === identityRegistry) {
1431
+ globalThis.__exactDomIdentity = previousIdentity;
1432
+ }
1433
+ if (getHostChannelStyleApplier() === channelApplier) {
1434
+ setHostChannelStyleApplier(previousChannelApplier);
1435
+ }
1436
+ for (const container of rootContainers.values()) {
1437
+ container.parentNode?.removeChild(container);
1438
+ }
1439
+ domByViewId.clear();
1440
+ rootContainers.clear();
1441
+ },
1442
+ };
1443
+ }
1444
+
1445
+ export function installDomMirror(options: DomMirrorOptions = {}): DomMirrorHandle | null {
1446
+ return installDomHost(options, {
1447
+ exposeDevIdentity: true,
1448
+ rootAttribute: 'data-exact-mirror-root',
1449
+ });
1450
+ }
1451
+
1452
+ export function installContractWebHost(
1453
+ options: ContractWebHostOptions = {},
1454
+ ): ContractWebHostHandle | null {
1455
+ return installDomHost(options, {
1456
+ exposeDevIdentity: false,
1457
+ rootAttribute: 'data-exact-contract-host-root',
1458
+ });
1459
+ }