@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,816 @@
1
+ /**
2
+ * Prop Normalization
3
+ *
4
+ * This module converts user-provided props into the canonical
5
+ * internal format used by the protocol encoder.
6
+ *
7
+ * Design Principles:
8
+ * - Web props are processed with web semantics (src, alt, etc.)
9
+ * - RN props are processed with RN semantics (source, resizeMode)
10
+ * - Clear prop extraction for different element types
11
+ */
12
+
13
+ import type {
14
+ AccessibilityActionDescriptor,
15
+ CanonicalProps,
16
+ CanonicalTagType,
17
+ ColorSchemeImageSource,
18
+ ImagePlaceholder,
19
+ ResizeMode,
20
+ ImageSource,
21
+ SelectableMode,
22
+ SvgSource,
23
+ } from '../types.js';
24
+ import { isAssetRef } from '@exact/core/assets-fonts-state';
25
+ import { lowerTextPropsToParagraphSpec } from '../text/paragraph-lowering.js';
26
+ import {
27
+ resolveImagePlaceholderForNative,
28
+ resolveImageSourceForNative,
29
+ warnIfImageMissingAlt,
30
+ } from '../image-source.js';
31
+ import { resolveSvgSourceForNative } from '../svg-source.js';
32
+
33
+ /**
34
+ * Extract text content from children.
35
+ * Handles both string children and nested text.
36
+ *
37
+ * @param children - The children prop
38
+ * @returns The text content string, or undefined if not text
39
+ */
40
+ export function extractTextContent(children: unknown): string | undefined {
41
+ if (children === null || children === undefined) {
42
+ return undefined;
43
+ }
44
+
45
+ if (typeof children === 'string') {
46
+ return children;
47
+ }
48
+
49
+ if (typeof children === 'number') {
50
+ return String(children);
51
+ }
52
+
53
+ if (typeof children === 'boolean') {
54
+ // React ignores booleans
55
+ return undefined;
56
+ }
57
+
58
+ // Array of children - concatenate text
59
+ if (Array.isArray(children)) {
60
+ const parts: string[] = [];
61
+ for (const child of children) {
62
+ const text = extractTextContent(child);
63
+ if (text !== undefined) {
64
+ parts.push(text);
65
+ }
66
+ }
67
+ return parts.length > 0 ? parts.join('') : undefined;
68
+ }
69
+
70
+ if (typeof children === 'object' && children !== null && 'props' in children) {
71
+ const props = (children as { props?: unknown }).props;
72
+ if (typeof props === 'object' && props !== null && 'children' in props) {
73
+ return extractTextContent((props as { children?: unknown }).children);
74
+ }
75
+ }
76
+
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Extract image source from web-style props.
82
+ *
83
+ * @param props - The props object
84
+ * @returns The image source URL, or undefined
85
+ */
86
+ export function extractWebImageSource(props: Record<string, unknown>): string | undefined {
87
+ const themed = imageSourceFromThemeProps(props);
88
+ if (themed) {
89
+ return resolveImageSourceForNative(themed);
90
+ }
91
+
92
+ const src = props.src;
93
+ if (typeof src === 'string' && src.length > 0) {
94
+ return src;
95
+ }
96
+ if (typeof src === 'object' && src !== null) {
97
+ return resolveImageSourceForNative(src as ImageSource);
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ /**
103
+ * Extract image source from RN-style props.
104
+ *
105
+ * @param props - The props object
106
+ * @returns The image source URL, or undefined
107
+ */
108
+ export function extractRNImageSource(props: Record<string, unknown>): string | undefined {
109
+ const themed = imageSourceFromThemeProps(props);
110
+ if (themed) {
111
+ return resolveImageSourceForNative(themed);
112
+ }
113
+
114
+ const source = props.source as ImageSource | undefined;
115
+ if (!source) {
116
+ return undefined;
117
+ }
118
+
119
+ return resolveImageSourceForNative(source);
120
+ }
121
+
122
+ function imageSourceFromThemeProps(props: Record<string, unknown>): ColorSchemeImageSource | undefined {
123
+ const light = typeof props.lightSrc === 'string' && props.lightSrc.length > 0
124
+ ? props.lightSrc
125
+ : undefined;
126
+ const dark = typeof props.darkSrc === 'string' && props.darkSrc.length > 0
127
+ ? props.darkSrc
128
+ : undefined;
129
+ if (!light && !dark) {
130
+ return undefined;
131
+ }
132
+
133
+ return {
134
+ ...(light ? { light } : {}),
135
+ ...(dark ? { dark } : {}),
136
+ ...(typeof props.src === 'string' && props.src.length > 0 ? { fallback: props.src } : {}),
137
+ };
138
+ }
139
+
140
+ function extractSvgSource(props: Record<string, unknown>): string | undefined {
141
+ const source = props.source as SvgSource | undefined;
142
+ if (!source) {
143
+ return undefined;
144
+ }
145
+
146
+ if (typeof source === 'string' && source.length > 0) {
147
+ return source;
148
+ }
149
+
150
+ if (isAssetRef(source)) {
151
+ return source.url;
152
+ }
153
+
154
+ if (typeof source === 'object' && source !== null && typeof source.uri === 'string' && source.uri.length > 0) {
155
+ return source.uri;
156
+ }
157
+
158
+ return undefined;
159
+ }
160
+
161
+ function extractSelectableMode(value: unknown): SelectableMode | undefined {
162
+ if (typeof value === 'boolean') {
163
+ return value;
164
+ }
165
+
166
+ if (value === 'contain' || value === 'all') {
167
+ return value;
168
+ }
169
+
170
+ return undefined;
171
+ }
172
+
173
+ function readBooleanProp(
174
+ props: Record<string, unknown>,
175
+ key: string
176
+ ): boolean | undefined {
177
+ return typeof props[key] === 'boolean' ? props[key] as boolean : undefined;
178
+ }
179
+
180
+ /**
181
+ * Compose the Liquid Glass inputs (`glassEffect`, `glass`, `glassVariant`,
182
+ * `glassTint`, `glassInteractive`) into the single `glassEffect` spec string
183
+ * the protocol carries. Returns `undefined` when no glass input is present at
184
+ * all (the caller keeps any boolean glassEffect the toggle path set, so the
185
+ * legacy truthy `"true"` value survives for native controls) and the explicit
186
+ * `"false"` spec for an explicit off — see the removal note in the body.
187
+ *
188
+ * Spec grammar: `<variant>` (`true` | `regular` | `clear`) optionally followed
189
+ * by `;tint=<color>` and/or `;interactive`. Examples: `"regular"`, `"clear"`,
190
+ * `"regular;tint=#7c4dffaa"`, `"clear;tint=#ff8800;interactive"`.
191
+ */
192
+ function composeGlassSpec(
193
+ props: Record<string, unknown>,
194
+ existing: CanonicalProps['glassEffect'],
195
+ ): string | boolean | undefined {
196
+ // `glass` is the Contract alias for `glassEffect`; accept either, and accept a
197
+ // string value (`glass="clear"`) as both enable + variant.
198
+ const enableRaw = props.glassEffect ?? props.glass;
199
+ // An explicit off wins even when a variant/tint is present, so a single
200
+ // `glass={flag}` toggle can switch a fully-specified surface on and off.
201
+ // Emit the explicit "false" spec rather than dropping the prop: the
202
+ // protocol's prop encoding has no removal op, so an omitted `glassEffect`
203
+ // leaves a previously glassy node's stale spec live on the native side and
204
+ // the material never tears down (ENG-22484 iOS visual pass). Both native
205
+ // parsers (`applyGlassSpec`) and the web fallback read "false" as off.
206
+ if (enableRaw === false || enableRaw === 'false') {
207
+ return 'false';
208
+ }
209
+ const variantInput =
210
+ readStringProp(props, 'glassVariant') ??
211
+ (typeof enableRaw === 'string' ? enableRaw : undefined);
212
+ const tint = readStringProp(props, 'glassTint');
213
+ const interactive = readBooleanProp(props, 'glassInteractive') === true;
214
+ const enabled =
215
+ enableRaw === true ||
216
+ typeof enableRaw === 'string' ||
217
+ variantInput !== undefined ||
218
+ tint !== undefined ||
219
+ interactive ||
220
+ existing === true ||
221
+ (typeof existing === 'string' && existing.length > 0 && existing !== 'false');
222
+ if (!enabled) {
223
+ return undefined;
224
+ }
225
+ const hasRefinement =
226
+ variantInput !== undefined || tint !== undefined || interactive;
227
+ if (!hasRefinement) {
228
+ // Bare enable: keep glassEffect a plain boolean. The encoder stringifies it
229
+ // to "true" (which the native toggle's `is_truthy` check matches and the
230
+ // container glass parser reads as the regular variant), so nothing is lost
231
+ // and the toggle path's boolean contract is preserved.
232
+ return true;
233
+ }
234
+ const variant = variantInput === 'clear' ? 'clear' : 'regular';
235
+ const parts = [variant];
236
+ if (tint !== undefined) {
237
+ parts.push(`tint=${tint}`);
238
+ }
239
+ if (interactive) {
240
+ parts.push('interactive');
241
+ }
242
+ return parts.join(';');
243
+ }
244
+
245
+ function readStringProp(
246
+ props: Record<string, unknown>,
247
+ key: string
248
+ ): string | undefined {
249
+ return typeof props[key] === 'string' && (props[key] as string).length > 0
250
+ ? props[key] as string
251
+ : undefined;
252
+ }
253
+
254
+ function readNumberProp(
255
+ props: Record<string, unknown>,
256
+ key: string
257
+ ): number | undefined {
258
+ return typeof props[key] === 'number' && isFinite(props[key] as number)
259
+ ? props[key] as number
260
+ : undefined;
261
+ }
262
+
263
+ type PropAliasKind = 'string' | 'rawString' | 'boolean' | 'number' | 'truncNumber';
264
+ type PropAliasEncoding = readonly [keyof CanonicalProps, PropAliasKind, ...string[]];
265
+
266
+ const TEST_ID_PROP_ALIASES: readonly PropAliasEncoding[] = [
267
+ ['testId', 'string', 'testId', 'testID', 'data-testid', 'data-test-id'],
268
+ ];
269
+
270
+ const COMMON_PROP_ALIASES: readonly PropAliasEncoding[] = [
271
+ ['inert', 'boolean', 'inert'],
272
+ ['scrollLocked', 'boolean', 'scrollLocked'],
273
+ ['portalTarget', 'string', 'portalTarget'],
274
+ ['accessibilityRole', 'string', 'accessibilityRole', 'role'],
275
+ ['accessibilityHint', 'string', 'accessibilityHint', 'aria-description'],
276
+ ['accessibilityModal', 'boolean', 'accessibilityModal', 'aria-modal'],
277
+ ['accessibilityExpanded', 'boolean', 'accessibilityExpanded', 'aria-expanded'],
278
+ ['accessibilitySelected', 'boolean', 'accessibilitySelected', 'aria-selected'],
279
+ ['accessibilityDisabled', 'boolean', 'accessibilityDisabled', 'aria-disabled'],
280
+ ['accessibilityLive', 'string', 'accessibilityLive', 'aria-live'],
281
+ ['accessibilityValueNow', 'number', 'accessibilityValueNow', 'aria-valuenow'],
282
+ ['accessibilityValueMin', 'number', 'accessibilityValueMin', 'aria-valuemin'],
283
+ ['accessibilityValueMax', 'number', 'accessibilityValueMax', 'aria-valuemax'],
284
+ ['accessibilityValueText', 'string', 'accessibilityValueText', 'aria-valuetext'],
285
+ ['nativeID', 'string', 'nativeID', 'id'],
286
+ ['href', 'string', 'href'],
287
+ ['accessibilityLabelledBy', 'string', 'accessibilityLabelledBy', 'aria-labelledby'],
288
+ ['accessibilityDescribedBy', 'string', 'accessibilityDescribedBy', 'aria-describedby'],
289
+ ['accessibilityBusy', 'boolean', 'accessibilityBusy', 'aria-busy'],
290
+ ['accessibilityElementsHidden', 'boolean', 'accessibilityElementsHidden', 'aria-hidden'],
291
+ ['accessibilityHeadingLevel', 'truncNumber', 'accessibilityHeadingLevel', 'aria-level'],
292
+ ['accessibilitySynthetic', 'boolean', 'accessibilitySynthetic'],
293
+ ['allowFontScaling', 'boolean', 'allowFontScaling'],
294
+ ['maxFontSizeMultiplier', 'number', 'maxFontSizeMultiplier'],
295
+ ['minimumFontSize', 'number', 'minimumFontSize'],
296
+ ['__exactPresencePhase', 'string', '__exactPresencePhase'],
297
+ ['__exactPortalLevel', 'string', '__exactPortalLevel'],
298
+ ['__exactPortalPresentation', 'string', '__exactPortalPresentation'],
299
+ ['__exactDismissableLayer', 'boolean', '__exactDismissableLayer'],
300
+ ['__exactDismissAction', 'string', '__exactDismissAction'],
301
+ ['__exactFocusRestore', 'boolean', '__exactFocusRestore'],
302
+ ['__exactAnchorTarget', 'string', '__exactAnchorTarget'],
303
+ ['__exactAnchorPlacement', 'string', '__exactAnchorPlacement'],
304
+ ['__exactAnchorStrategy', 'string', '__exactAnchorStrategy'],
305
+ ['__exactAnchorOffset', 'number', '__exactAnchorOffset'],
306
+ ['__exactComponentName', 'string', '__exactComponentName'],
307
+ ['__exactComponentSlot', 'string', '__exactComponentSlot'],
308
+ ['__exactSourceFilePath', 'string', '__exactSourceFilePath'],
309
+ ['__exactVariantProps', 'string', '__exactVariantProps'],
310
+ ['__exactInteractionState', 'string', '__exactInteractionState'],
311
+ ['__exactRenderMode', 'string', '__exactRenderMode'],
312
+ ['disabled', 'boolean', 'disabled'],
313
+ ['disabledReason', 'rawString', 'disabledReason'],
314
+ ['agentSummary', 'rawString', 'agentSummary'],
315
+ ];
316
+
317
+ const AGENT_ID_PROP_ALIASES: readonly PropAliasEncoding[] = [
318
+ ['agentId', 'rawString', 'agentId'],
319
+ ];
320
+
321
+ function readAliasProp(
322
+ props: Record<string, unknown>,
323
+ encoding: PropAliasEncoding,
324
+ ): unknown {
325
+ const kind = encoding[1];
326
+ for (let index = 2; index < encoding.length; index += 1) {
327
+ const key = encoding[index] as string;
328
+ const value =
329
+ kind === 'string'
330
+ ? readStringProp(props, key)
331
+ : kind === 'rawString'
332
+ ? (typeof props[key] === 'string' ? props[key] as string : undefined)
333
+ : kind === 'boolean'
334
+ ? readBooleanProp(props, key)
335
+ : readNumberProp(props, key);
336
+
337
+ if (value !== undefined) {
338
+ return kind === 'truncNumber' ? Math.trunc(value as number) : value;
339
+ }
340
+ }
341
+ return undefined;
342
+ }
343
+
344
+ function assignPropAliases(
345
+ canonical: CanonicalProps,
346
+ props: Record<string, unknown>,
347
+ encodings: readonly PropAliasEncoding[],
348
+ ): void {
349
+ const target = canonical as Record<string, unknown>;
350
+ for (const encoding of encodings) {
351
+ const value = readAliasProp(props, encoding);
352
+ if (value !== undefined) {
353
+ target[encoding[0]] = value;
354
+ }
355
+ }
356
+ }
357
+
358
+ function normalizeAccessibilityActions(
359
+ value: unknown,
360
+ ): string | undefined {
361
+ if (!Array.isArray(value)) {
362
+ return undefined;
363
+ }
364
+
365
+ const actions = value
366
+ .filter((candidate): candidate is AccessibilityActionDescriptor => (
367
+ typeof candidate === 'object' &&
368
+ candidate !== null &&
369
+ typeof (candidate as AccessibilityActionDescriptor).name === 'string'
370
+ ))
371
+ .map((candidate) => ({
372
+ name: candidate.name.trim(),
373
+ label:
374
+ typeof candidate.label === 'string' && candidate.label.trim().length > 0
375
+ ? candidate.label.trim()
376
+ : undefined,
377
+ }))
378
+ .filter((candidate) => candidate.name.length > 0);
379
+
380
+ if (actions.length === 0) {
381
+ return undefined;
382
+ }
383
+
384
+ return JSON.stringify(actions);
385
+ }
386
+
387
+ function normalizeAccessibilityOrder(
388
+ value: unknown,
389
+ ): string | undefined {
390
+ if (!Array.isArray(value)) {
391
+ return undefined;
392
+ }
393
+
394
+ const order = value
395
+ .map((candidate) => {
396
+ if (typeof candidate === 'string') {
397
+ return candidate.trim();
398
+ }
399
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
400
+ return String(candidate);
401
+ }
402
+ return '';
403
+ })
404
+ .filter((candidate) => candidate.length > 0);
405
+
406
+ if (order.length === 0) {
407
+ return undefined;
408
+ }
409
+
410
+ return JSON.stringify(order);
411
+ }
412
+
413
+ /**
414
+ * Map web objectFit to RN-style resizeMode.
415
+ */
416
+ function mapObjectFitToResizeMode(
417
+ objectFit: string | undefined
418
+ ): ResizeMode | undefined {
419
+ switch (objectFit) {
420
+ case 'cover':
421
+ case 'contain':
422
+ return objectFit;
423
+ case 'fill':
424
+ return 'stretch';
425
+ case 'none':
426
+ return 'center';
427
+ case 'scale-down':
428
+ return 'contain';
429
+ default:
430
+ return undefined;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Extract resize mode from props.
436
+ *
437
+ * @param props - The props object
438
+ * @param isRNStyle - Whether to use RN prop names
439
+ * @returns The resize mode, or undefined
440
+ */
441
+ export function extractResizeMode(
442
+ props: Record<string, unknown>,
443
+ isRNStyle: boolean
444
+ ): ResizeMode | undefined {
445
+ if (isRNStyle) {
446
+ const resizeMode = props.resizeMode;
447
+ if (
448
+ resizeMode === 'cover' ||
449
+ resizeMode === 'contain' ||
450
+ resizeMode === 'stretch' ||
451
+ resizeMode === 'center'
452
+ ) {
453
+ return resizeMode;
454
+ }
455
+ }
456
+
457
+ const objectFit = props.objectFit;
458
+ return typeof objectFit === 'string'
459
+ ? mapObjectFitToResizeMode(objectFit)
460
+ : undefined;
461
+ }
462
+
463
+ /**
464
+ * Normalize props for an element into canonical format.
465
+ *
466
+ * @param tagType - The canonical tag type
467
+ * @param props - The user-provided props
468
+ * @param isRNStyle - Whether to use RN prop names
469
+ * @returns The normalized canonical props
470
+ */
471
+ export function normalizeProps(
472
+ tagType: CanonicalTagType,
473
+ props: Record<string, unknown>,
474
+ isRNStyle: boolean
475
+ ): CanonicalProps {
476
+ const canonical: CanonicalProps = {};
477
+
478
+ switch (tagType) {
479
+ case 'text': {
480
+ const paragraph = lowerTextPropsToParagraphSpec(props);
481
+ if (paragraph !== null) {
482
+ canonical.textContent = paragraph.plainText;
483
+ } else if (typeof props.textContent === 'string') {
484
+ canonical.textContent = props.textContent;
485
+ }
486
+ break;
487
+ }
488
+
489
+ case 'image': {
490
+ warnIfImageMissingAlt({
491
+ alt: typeof props.alt === 'string' ? props.alt : undefined,
492
+ decorative: props.decorative === true,
493
+ source: (imageSourceFromThemeProps(props) ?? (isRNStyle ? props.source : props.src)) as ImageSource | undefined,
494
+ testID: typeof props.testID === 'string' ? props.testID : undefined,
495
+ });
496
+
497
+ // Extract image source
498
+ if (isRNStyle) {
499
+ const source = extractRNImageSource(props);
500
+ if (source) {
501
+ canonical.imageSource = source;
502
+ }
503
+ } else {
504
+ const source = extractWebImageSource(props);
505
+ if (source) {
506
+ canonical.imageSource = source;
507
+ }
508
+ }
509
+
510
+ // Inline-SVG glyphs (Contract `image svgSource=...`, the dom-mirror's
511
+ // `svgSource` host prop) carry their artwork as markup rather than a
512
+ // `src`. The DOM mirror inlines that markup into a live `<svg>`; native
513
+ // cannot, so resolve it to a `data:image/svg+xml` URI the image pipeline
514
+ // loads, and forward `tintColor` as the glyph color (read for image
515
+ // nodes by the AppKit/UIKit presenters). A raster `src`, if present,
516
+ // wins.
517
+ if (canonical.imageSource === undefined && props.svgSource !== undefined) {
518
+ const svgImageSource = resolveSvgSourceForNative(props.svgSource as SvgSource);
519
+ if (svgImageSource !== undefined) {
520
+ canonical.imageSource = svgImageSource;
521
+ if (typeof props.tintColor === 'string') {
522
+ canonical.tintColor = props.tintColor;
523
+ }
524
+ }
525
+ }
526
+
527
+ // Extract resize mode
528
+ const resizeMode = extractResizeMode(props, isRNStyle);
529
+ if (resizeMode) {
530
+ canonical.resizeMode = resizeMode;
531
+ }
532
+
533
+ const placeholder = resolveImagePlaceholderForNative(
534
+ props.placeholder as ImagePlaceholder | undefined,
535
+ );
536
+ if (placeholder) {
537
+ canonical.placeholder = placeholder;
538
+ }
539
+ break;
540
+ }
541
+
542
+ case 'svg': {
543
+ warnIfImageMissingAlt({
544
+ alt: typeof props.alt === 'string' ? props.alt : undefined,
545
+ decorative: props.decorative === true,
546
+ source: props.source as ImageSource | undefined,
547
+ testID: typeof props.testID === 'string' ? props.testID : undefined,
548
+ });
549
+
550
+ const source = extractSvgSource(props);
551
+ if (source) {
552
+ canonical.svgSource = source;
553
+ }
554
+
555
+ const resizeMode = extractResizeMode(props, true);
556
+ if (resizeMode) {
557
+ canonical.resizeMode = resizeMode;
558
+ }
559
+
560
+ if (typeof props.tintColor === 'string') {
561
+ canonical.tintColor = props.tintColor;
562
+ }
563
+
564
+ if (typeof props.objectPosition === 'string' && props.objectPosition.trim().length > 0) {
565
+ canonical.svgObjectPosition = props.objectPosition.trim();
566
+ }
567
+
568
+ if (typeof props.pixelDensity === 'number' && isFinite(props.pixelDensity) && props.pixelDensity > 0) {
569
+ canonical.svgPixelDensity = String(props.pixelDensity);
570
+ }
571
+
572
+ if (typeof props.colors === 'object' && props.colors !== null) {
573
+ const colorEntries = Object.entries(props.colors as Record<string, unknown>)
574
+ .filter(([key, value]) => key.startsWith('--') && typeof value === 'string');
575
+ if (colorEntries.length > 0) {
576
+ canonical.svgColors = JSON.stringify(Object.fromEntries(colorEntries));
577
+ }
578
+ }
579
+ break;
580
+ }
581
+
582
+ case 'video': {
583
+ if (typeof props.videoPlayerId === 'string' && props.videoPlayerId.length > 0) {
584
+ canonical.videoPlayerId = props.videoPlayerId;
585
+ }
586
+
587
+ if (typeof props.videoViewConfig === 'string' && props.videoViewConfig.length > 0) {
588
+ canonical.videoViewConfig = props.videoViewConfig;
589
+ }
590
+ break;
591
+ }
592
+
593
+ case 'input': {
594
+ // Text input props
595
+ if (typeof props.value === 'string') {
596
+ canonical.value = props.value;
597
+ } else if (typeof props.defaultValue === 'string') {
598
+ canonical.value = props.defaultValue;
599
+ }
600
+
601
+ if (typeof props.placeholder === 'string') {
602
+ canonical.placeholder = props.placeholder;
603
+ }
604
+
605
+ if (typeof props.editable === 'boolean') {
606
+ canonical.editable = props.editable;
607
+ }
608
+
609
+ if (typeof props.multiline === 'boolean') {
610
+ canonical.multiline = props.multiline;
611
+ }
612
+
613
+ if (typeof props.maxLength === 'number' && props.maxLength > 0) {
614
+ canonical.maxLength = props.maxLength;
615
+ }
616
+
617
+ if (typeof props.secureTextEntry === 'boolean') {
618
+ canonical.secureTextEntry = props.secureTextEntry;
619
+ }
620
+ break;
621
+ }
622
+
623
+ case 'scroll':
624
+ case 'list': {
625
+ // Scroll container props
626
+ if (typeof props.horizontal === 'boolean') {
627
+ canonical.horizontal = props.horizontal;
628
+ }
629
+
630
+ // Combine indicator visibility
631
+ const showsV = props.showsVerticalScrollIndicator;
632
+ const showsH = props.showsHorizontalScrollIndicator;
633
+ if (typeof showsV === 'boolean' || typeof showsH === 'boolean') {
634
+ canonical.showsScrollIndicator = showsV !== false && showsH !== false;
635
+ }
636
+ break;
637
+ }
638
+
639
+ case 'view':
640
+ case 'nativeView':
641
+ case 'pressable':
642
+ // View and pressable don't have special props
643
+ break;
644
+
645
+ case 'toggle': {
646
+ // Toggle/switch props
647
+ if (typeof props.value === 'boolean') {
648
+ canonical.toggleValue = props.value;
649
+ } else if (typeof props.defaultValue === 'boolean') {
650
+ canonical.toggleValue = props.defaultValue;
651
+ }
652
+
653
+ if (typeof props.glassEffect === 'boolean') {
654
+ canonical.glassEffect = props.glassEffect;
655
+ }
656
+
657
+ if (typeof props.tintColor === 'string') {
658
+ canonical.tintColor = props.tintColor;
659
+ }
660
+
661
+ if (typeof props.disabled === 'boolean') {
662
+ canonical.disabled = props.disabled;
663
+ }
664
+
665
+ // Extract label from children or label prop
666
+ const label = typeof props.label === 'string'
667
+ ? props.label
668
+ : extractTextContent(props.children);
669
+ if (label !== undefined) {
670
+ canonical.textContent = label;
671
+ }
672
+ break;
673
+ }
674
+ }
675
+
676
+ const selectable = extractSelectableMode(props.selectable);
677
+ if (selectable !== undefined) {
678
+ canonical.selectable = selectable;
679
+ }
680
+
681
+ if (typeof props.lang === 'string' && props.lang.trim().length > 0) {
682
+ canonical.lang = props.lang.trim();
683
+ }
684
+
685
+ if (typeof props.selectionCopyText === 'string') {
686
+ canonical.selectionCopyText = props.selectionCopyText;
687
+ }
688
+
689
+ if (
690
+ tagType === 'input' &&
691
+ typeof props.selection === 'object' &&
692
+ props.selection !== null
693
+ ) {
694
+ const selection = props.selection as { start?: unknown; end?: unknown };
695
+ if (
696
+ typeof selection.start === 'number' &&
697
+ typeof selection.end === 'number' &&
698
+ Number.isFinite(selection.start) &&
699
+ Number.isFinite(selection.end) &&
700
+ selection.start >= 0 &&
701
+ selection.end >= 0
702
+ ) {
703
+ canonical.selectionStart = selection.start;
704
+ canonical.selectionEnd = selection.end;
705
+ }
706
+ }
707
+
708
+ // Common props
709
+
710
+ // Liquid Glass (Apple iOS 26 / macOS 26). The `glass*` inputs compose into a
711
+ // single `glassEffect` spec string that rides the existing PropId.GlassEffect
712
+ // wire prop. The bare-boolean form stays `true` so the native toggle's truthy
713
+ // check keeps working; richer forms ("regular"/"clear" + ;tint= + ;interactive)
714
+ // are parsed by the native host and the web fallback. The toggle case above
715
+ // may already have set a boolean glassEffect — only override when there is a
716
+ // container-glass refinement to encode.
717
+ const glassSpec = composeGlassSpec(props, canonical.glassEffect);
718
+ if (glassSpec !== undefined) {
719
+ canonical.glassEffect = glassSpec;
720
+ }
721
+
722
+ assignPropAliases(canonical, props, TEST_ID_PROP_ALIASES);
723
+
724
+ // Accessibility
725
+ if (props.decorative === true) {
726
+ canonical.accessibilityLabel = '';
727
+ } else if (typeof props.accessibilityLabel === 'string') {
728
+ canonical.accessibilityLabel = props.accessibilityLabel;
729
+ } else if (typeof props['aria-label'] === 'string') {
730
+ canonical.accessibilityLabel = props['aria-label'];
731
+ } else if (typeof props.alt === 'string') {
732
+ // Web alt attribute maps to accessibility label
733
+ canonical.accessibilityLabel = props.alt;
734
+ }
735
+
736
+ if (props.focusScope !== undefined && (typeof props.focusScope === 'boolean' || typeof props.focusScope === 'string')) {
737
+ canonical.focusScope = props.focusScope;
738
+ }
739
+
740
+ assignPropAliases(canonical, props, COMMON_PROP_ALIASES);
741
+
742
+ if (
743
+ typeof props.accessibilityChecked === 'boolean' ||
744
+ props.accessibilityChecked === 'mixed'
745
+ ) {
746
+ canonical.accessibilityChecked = props.accessibilityChecked;
747
+ } else if (
748
+ typeof props['aria-checked'] === 'boolean' ||
749
+ props['aria-checked'] === 'mixed'
750
+ ) {
751
+ canonical.accessibilityChecked = props['aria-checked'] as boolean | 'mixed';
752
+ }
753
+
754
+ const focusable =
755
+ readBooleanProp(props, 'focusable') ??
756
+ (typeof props.tabIndex === 'number' ? props.tabIndex >= 0 : undefined);
757
+ if (focusable !== undefined) {
758
+ canonical.focusable = focusable;
759
+ }
760
+
761
+ if (typeof props.tabIndex === 'number' && Number.isFinite(props.tabIndex)) {
762
+ canonical.tabIndex = Math.trunc(props.tabIndex);
763
+ }
764
+
765
+ const accessibilityActions = normalizeAccessibilityActions(props.accessibilityActions);
766
+ if (accessibilityActions !== undefined) {
767
+ canonical.accessibilityActions = accessibilityActions;
768
+ }
769
+
770
+ const accessibilityOrder = normalizeAccessibilityOrder(props.accessibilityOrder);
771
+ if (accessibilityOrder !== undefined) {
772
+ canonical.accessibilityOrder = accessibilityOrder;
773
+ }
774
+
775
+ // `agentSemantics` follows the same renderer-local rule as `agentSummary`,
776
+ // but it carries structured metadata. Keep it serialized so CanonicalProps
777
+ // stays shallow and the props equality check remains a cheap string compare.
778
+ if (props.agentSemantics != null && typeof props.agentSemantics === 'object') {
779
+ try {
780
+ const serialized = JSON.stringify(props.agentSemantics);
781
+ if (typeof serialized === 'string') {
782
+ canonical.agentSemantics = serialized;
783
+ }
784
+ } catch {
785
+ // Ignore non-serializable metadata rather than letting authoring-only
786
+ // hints break rendering. The app surface simply falls back to heuristics.
787
+ }
788
+ }
789
+
790
+ assignPropAliases(canonical, props, AGENT_ID_PROP_ALIASES);
791
+
792
+ return canonical;
793
+ }
794
+
795
+ /**
796
+ * Compare two canonical props and return true if they are equal.
797
+ *
798
+ * @param a - First props
799
+ * @param b - Second props
800
+ * @returns True if props are equal
801
+ */
802
+ export function propsEqual(a: CanonicalProps, b: CanonicalProps): boolean {
803
+ // Quick reference check
804
+ if (a === b) return true;
805
+
806
+ const keysA = Object.keys(a) as (keyof CanonicalProps)[];
807
+ const keysB = Object.keys(b) as (keyof CanonicalProps)[];
808
+
809
+ if (keysA.length !== keysB.length) return false;
810
+
811
+ for (const key of keysA) {
812
+ if (a[key] !== b[key]) return false;
813
+ }
814
+
815
+ return true;
816
+ }