@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,1426 @@
1
+ /**
2
+ * Style Normalization
3
+ *
4
+ * This module converts user-provided style objects into the canonical
5
+ * internal format used by the protocol encoder.
6
+ *
7
+ * Design Principles:
8
+ * - Input validation and clamping for safety
9
+ * - Consistent handling of shorthand properties
10
+ * - Clear precedence rules (specific > general)
11
+ * - Web defaults with RN compatibility mode
12
+ */
13
+
14
+ import type {
15
+ ViewStyle,
16
+ FullStyle,
17
+ CanonicalStyle,
18
+ TransitionConfig,
19
+ StyleTransition,
20
+ TransitionEasing,
21
+ TransitionEasingName,
22
+ CubicBezierEasing,
23
+ CanonicalTransitionConfig,
24
+ CanonicalTransitionMap,
25
+ CanonicalTransitionProperty,
26
+ DimensionValue,
27
+ DimensionInput,
28
+ FlexDirection,
29
+ Direction,
30
+ ResolvedDirection,
31
+ WritingMode,
32
+ } from '../types.js';
33
+ import { resolveFontFamilyId } from '@exact/core/assets-fonts-state';
34
+ import { parseColor } from './color.js';
35
+
36
+ const TIMING_DURATION_DEFAULT_MS = 250;
37
+ const TIMING_DELAY_DEFAULT_MS = 0;
38
+ const TIMING_EASING_DEFAULT: TransitionEasingName = 'easeInOut';
39
+
40
+ const SPRING_DELAY_DEFAULT_MS = 0;
41
+ const SPRING_DAMPING_DEFAULT = 16;
42
+ const SPRING_STIFFNESS_DEFAULT = 220;
43
+ const SPRING_MASS_DEFAULT = 1;
44
+ const SPRING_VELOCITY_DEFAULT = 0;
45
+
46
+ const FONT_VARIANT_NUMERIC_TABULAR_NUMS = 1 << 0;
47
+ const FONT_VARIANT_NUMERIC_PROPORTIONAL_NUMS = 1 << 1;
48
+ const FONT_VARIANT_NUMERIC_LINING_NUMS = 1 << 2;
49
+ const FONT_VARIANT_NUMERIC_OLDSTYLE_NUMS = 1 << 3;
50
+ const FONT_VARIANT_NUMERIC_SLASHED_ZERO = 1 << 4;
51
+
52
+ const FONT_WEIGHT_MAP: Readonly<Record<string, number>> = {
53
+ normal: 400,
54
+ bold: 700,
55
+ '100': 100,
56
+ '200': 200,
57
+ '300': 300,
58
+ '400': 400,
59
+ '500': 500,
60
+ '600': 600,
61
+ '700': 700,
62
+ '800': 800,
63
+ '900': 900,
64
+ };
65
+
66
+ const TRANSITION_PROPERTY_ALIASES: Record<string, CanonicalTransitionProperty> = {
67
+ opacity: 'opacity',
68
+ transform: 'transform',
69
+ 'background-color': 'backgroundColor',
70
+ backgroundcolor: 'backgroundColor',
71
+ backgroundColor: 'backgroundColor',
72
+ 'border-radius': 'borderRadius',
73
+ borderradius: 'borderRadius',
74
+ borderRadius: 'borderRadius',
75
+ };
76
+
77
+ const TRANSITION_EASING_ALIASES: Record<string, TransitionEasingName> = {
78
+ linear: 'linear',
79
+ ease: 'easeInOut',
80
+ 'ease-in': 'easeIn',
81
+ easeIn: 'easeIn',
82
+ 'ease-out': 'easeOut',
83
+ easeOut: 'easeOut',
84
+ 'ease-in-out': 'easeInOut',
85
+ easeInOut: 'easeInOut',
86
+ };
87
+
88
+ export interface NormalizeStyleOptions {
89
+ resolvedDirection?: ResolvedDirection;
90
+ resolvedWritingMode?: WritingMode;
91
+ includeInheritedValues?: boolean;
92
+ }
93
+
94
+ type CanonicalDimensionKey = Extract<keyof CanonicalStyle,
95
+ | 'width'
96
+ | 'height'
97
+ | 'minWidth'
98
+ | 'minHeight'
99
+ | 'maxWidth'
100
+ | 'maxHeight'
101
+ | 'paddingTop'
102
+ | 'paddingRight'
103
+ | 'paddingBottom'
104
+ | 'paddingLeft'
105
+ | 'marginTop'
106
+ | 'marginRight'
107
+ | 'marginBottom'
108
+ | 'marginLeft'
109
+ | 'flexBasis'
110
+ | 'top'
111
+ | 'right'
112
+ | 'bottom'
113
+ | 'left'
114
+ >;
115
+
116
+ const BOX_DIMENSION_KEYS = [
117
+ 'width',
118
+ 'height',
119
+ 'minWidth',
120
+ 'minHeight',
121
+ 'maxWidth',
122
+ 'maxHeight',
123
+ ] as const satisfies readonly CanonicalDimensionKey[];
124
+
125
+ const PADDING_KEYS = {
126
+ top: 'paddingTop',
127
+ right: 'paddingRight',
128
+ bottom: 'paddingBottom',
129
+ left: 'paddingLeft',
130
+ } as const satisfies Record<PhysicalEdge, CanonicalDimensionKey>;
131
+
132
+ const MARGIN_KEYS = {
133
+ top: 'marginTop',
134
+ right: 'marginRight',
135
+ bottom: 'marginBottom',
136
+ left: 'marginLeft',
137
+ } as const satisfies Record<PhysicalEdge, CanonicalDimensionKey>;
138
+
139
+ const INSET_KEYS = {
140
+ top: 'top',
141
+ right: 'right',
142
+ bottom: 'bottom',
143
+ left: 'left',
144
+ } as const satisfies Record<PhysicalEdge, CanonicalDimensionKey>;
145
+
146
+ const PADDING_EDGE_KEYS = [
147
+ 'paddingTop',
148
+ 'paddingRight',
149
+ 'paddingBottom',
150
+ 'paddingLeft',
151
+ ] as const satisfies readonly CanonicalDimensionKey[];
152
+
153
+ const MARGIN_EDGE_KEYS = [
154
+ 'marginTop',
155
+ 'marginRight',
156
+ 'marginBottom',
157
+ 'marginLeft',
158
+ ] as const satisfies readonly CanonicalDimensionKey[];
159
+
160
+ const POSITION_TRAILING_EDGE_KEYS = [
161
+ 'right',
162
+ 'bottom',
163
+ 'left',
164
+ ] as const satisfies readonly CanonicalDimensionKey[];
165
+
166
+ type DirectStyleAssignment = readonly [keyof CanonicalStyle, keyof FullStyle];
167
+ type NumberStyleKind = 'finite' | 'positive' | 'nonNegative' | 'unit' | 'floorPositive';
168
+ type NumberStyleAssignment = readonly [keyof CanonicalStyle, keyof FullStyle, NumberStyleKind];
169
+
170
+ const FLEX_DIRECT_ASSIGNMENTS: readonly DirectStyleAssignment[] = [
171
+ ['flexWrap', 'flexWrap'],
172
+ ['justifyContent', 'justifyContent'],
173
+ ['alignItems', 'alignItems'],
174
+ ['alignSelf', 'alignSelf'],
175
+ ];
176
+
177
+ const FLEX_NUMBER_ASSIGNMENTS: readonly NumberStyleAssignment[] = [
178
+ ['flexGrow', 'flexGrow', 'finite'],
179
+ ['flexShrink', 'flexShrink', 'finite'],
180
+ ];
181
+
182
+ const GAP_NUMBER_ASSIGNMENTS: readonly NumberStyleAssignment[] = [
183
+ ['rowGap', 'rowGap', 'finite'],
184
+ ['columnGap', 'columnGap', 'finite'],
185
+ ];
186
+
187
+ const APPEARANCE_NUMBER_ASSIGNMENTS: readonly NumberStyleAssignment[] = [
188
+ ['opacity', 'opacity', 'unit'],
189
+ ['borderRadius', 'borderRadius', 'nonNegative'],
190
+ ['borderTopLeftRadius', 'borderTopLeftRadius', 'nonNegative'],
191
+ ['borderTopRightRadius', 'borderTopRightRadius', 'nonNegative'],
192
+ ['borderBottomLeftRadius', 'borderBottomLeftRadius', 'nonNegative'],
193
+ ['borderBottomRightRadius', 'borderBottomRightRadius', 'nonNegative'],
194
+ ['borderWidth', 'borderWidth', 'nonNegative'],
195
+ ['borderTopWidth', 'borderTopWidth', 'nonNegative'],
196
+ ['borderRightWidth', 'borderRightWidth', 'nonNegative'],
197
+ ['borderBottomWidth', 'borderBottomWidth', 'nonNegative'],
198
+ ['borderLeftWidth', 'borderLeftWidth', 'nonNegative'],
199
+ ['backdropBlur', 'backdropBlur', 'nonNegative'],
200
+ ['aspectRatio', 'aspectRatio', 'positive'],
201
+ ];
202
+
203
+ const SHADOW_NUMBER_ASSIGNMENTS: readonly NumberStyleAssignment[] = [
204
+ ['shadowRadius', 'shadowRadius', 'nonNegative'],
205
+ ['shadowOpacity', 'shadowOpacity', 'unit'],
206
+ ];
207
+
208
+ const TYPOGRAPHY_NUMBER_ASSIGNMENTS: readonly NumberStyleAssignment[] = [
209
+ ['fontSize', 'fontSize', 'positive'],
210
+ ['lineHeight', 'lineHeight', 'finite'],
211
+ ['letterSpacing', 'letterSpacing', 'finite'],
212
+ ['numberOfLines', 'numberOfLines', 'floorPositive'],
213
+ ];
214
+
215
+ /**
216
+ * Parse a dimension input into a canonical dimension value.
217
+ *
218
+ * @param input - The dimension input (number, string, or explicit)
219
+ * @returns The canonical dimension value, or undefined if invalid
220
+ */
221
+ export function parseDimension(input: DimensionInput | undefined): DimensionValue | undefined {
222
+ if (input === undefined || input === null) {
223
+ return undefined;
224
+ }
225
+
226
+ // Number is interpreted as points
227
+ if (typeof input === 'number') {
228
+ if (!isFinite(input)) {
229
+ return undefined;
230
+ }
231
+ return { type: 'points', value: input };
232
+ }
233
+
234
+ // String parsing
235
+ if (typeof input === 'string') {
236
+ const trimmed = input.trim();
237
+
238
+ if (trimmed === 'auto') {
239
+ return { type: 'auto' };
240
+ }
241
+
242
+ // Check for percentage
243
+ if (trimmed.endsWith('%')) {
244
+ const value = parseFloat(trimmed.slice(0, -1));
245
+ if (!isFinite(value)) {
246
+ return undefined;
247
+ }
248
+ return { type: 'percent', value };
249
+ }
250
+
251
+ // Check for px suffix
252
+ if (trimmed.endsWith('px')) {
253
+ const value = parseFloat(trimmed.slice(0, -2));
254
+ if (!isFinite(value)) {
255
+ return undefined;
256
+ }
257
+ return { type: 'points', value };
258
+ }
259
+
260
+ // Try parsing as a number
261
+ const value = parseFloat(trimmed);
262
+ if (!isFinite(value)) {
263
+ return undefined;
264
+ }
265
+ return { type: 'points', value };
266
+ }
267
+
268
+ // Already a dimension value object
269
+ if (typeof input === 'object' && 'type' in input) {
270
+ if (input.type === 'auto') {
271
+ return { type: 'auto' };
272
+ }
273
+ if ((input.type === 'points' || input.type === 'percent') && typeof input.value === 'number') {
274
+ if (!isFinite(input.value)) {
275
+ return undefined;
276
+ }
277
+ return input as DimensionValue;
278
+ }
279
+ }
280
+
281
+ return undefined;
282
+ }
283
+
284
+ function setDimension(
285
+ canonical: CanonicalStyle,
286
+ key: CanonicalDimensionKey,
287
+ input: DimensionInput | undefined,
288
+ ): void {
289
+ const value = parseDimension(input);
290
+ if (value) {
291
+ canonical[key] = value;
292
+ }
293
+ }
294
+
295
+ function setDimensionValue(
296
+ canonical: CanonicalStyle,
297
+ key: CanonicalDimensionKey,
298
+ value: DimensionValue,
299
+ ): void {
300
+ canonical[key] = value;
301
+ }
302
+
303
+ function setDimensionEdges(
304
+ canonical: CanonicalStyle,
305
+ keys: readonly CanonicalDimensionKey[],
306
+ value: DimensionValue,
307
+ ): void {
308
+ for (const key of keys) {
309
+ canonical[key] = value;
310
+ }
311
+ }
312
+
313
+ function normalizeNumberStyleValue(value: unknown, kind: NumberStyleKind): number | undefined {
314
+ if (typeof value !== 'number' || !isFinite(value)) {
315
+ return undefined;
316
+ }
317
+
318
+ switch (kind) {
319
+ case 'positive':
320
+ return value > 0 ? value : undefined;
321
+ case 'nonNegative':
322
+ return Math.max(0, value);
323
+ case 'unit':
324
+ return Math.max(0, Math.min(1, value));
325
+ case 'floorPositive':
326
+ return value > 0 ? Math.floor(value) : undefined;
327
+ case 'finite':
328
+ default:
329
+ return value;
330
+ }
331
+ }
332
+
333
+ function assignNumberStyles(
334
+ canonical: CanonicalStyle,
335
+ style: FullStyle,
336
+ assignments: readonly NumberStyleAssignment[],
337
+ ): void {
338
+ const target = canonical as Record<string, unknown>;
339
+ const source = style as Record<string, unknown>;
340
+ for (const [targetKey, sourceKey, kind] of assignments) {
341
+ const value = normalizeNumberStyleValue(source[sourceKey], kind);
342
+ if (value !== undefined) {
343
+ target[targetKey] = value;
344
+ }
345
+ }
346
+ }
347
+
348
+ function assignDefinedStyles(
349
+ canonical: CanonicalStyle,
350
+ style: FullStyle,
351
+ assignments: readonly DirectStyleAssignment[],
352
+ ): void {
353
+ const target = canonical as Record<string, unknown>;
354
+ const source = style as Record<string, unknown>;
355
+ for (const [targetKey, sourceKey] of assignments) {
356
+ const value = source[sourceKey];
357
+ if (value !== undefined) {
358
+ target[targetKey] = value;
359
+ }
360
+ }
361
+ }
362
+
363
+ function parseExplicitDirection(value: unknown): Direction | undefined {
364
+ return value === 'ltr' || value === 'rtl' || value === 'auto'
365
+ ? value
366
+ : undefined;
367
+ }
368
+
369
+ function parseWritingMode(value: unknown): WritingMode | undefined {
370
+ return value === 'horizontal-tb' || value === 'vertical-rl' || value === 'vertical-lr'
371
+ ? value
372
+ : undefined;
373
+ }
374
+
375
+ type PhysicalEdge = 'top' | 'right' | 'bottom' | 'left';
376
+
377
+ function inlineStartEdge(
378
+ direction: ResolvedDirection,
379
+ writingMode: WritingMode,
380
+ ): PhysicalEdge {
381
+ switch (writingMode) {
382
+ case 'vertical-rl':
383
+ case 'vertical-lr':
384
+ return direction === 'rtl' ? 'bottom' : 'top';
385
+ case 'horizontal-tb':
386
+ default:
387
+ return direction === 'rtl' ? 'right' : 'left';
388
+ }
389
+ }
390
+
391
+ function inlineEndEdge(
392
+ direction: ResolvedDirection,
393
+ writingMode: WritingMode,
394
+ ): PhysicalEdge {
395
+ switch (writingMode) {
396
+ case 'vertical-rl':
397
+ case 'vertical-lr':
398
+ return direction === 'rtl' ? 'top' : 'bottom';
399
+ case 'horizontal-tb':
400
+ default:
401
+ return direction === 'rtl' ? 'left' : 'right';
402
+ }
403
+ }
404
+
405
+ function assignPaddingEdge(
406
+ canonical: CanonicalStyle,
407
+ edge: PhysicalEdge,
408
+ value: DimensionValue,
409
+ ): void {
410
+ setDimensionValue(canonical, PADDING_KEYS[edge], value);
411
+ }
412
+
413
+ function assignMarginEdge(
414
+ canonical: CanonicalStyle,
415
+ edge: PhysicalEdge,
416
+ value: DimensionValue,
417
+ ): void {
418
+ setDimensionValue(canonical, MARGIN_KEYS[edge], value);
419
+ }
420
+
421
+ function assignInsetEdge(
422
+ canonical: CanonicalStyle,
423
+ edge: PhysicalEdge,
424
+ value: DimensionValue,
425
+ ): void {
426
+ setDimensionValue(canonical, INSET_KEYS[edge], value);
427
+ }
428
+
429
+ /**
430
+ * Parse a font weight value to a numeric weight (100-900).
431
+ *
432
+ * @param weight - The font weight input
433
+ * @returns The numeric weight, or undefined if invalid
434
+ */
435
+ export function parseFontWeight(weight: string | number | undefined): number | undefined {
436
+ if (weight === undefined || weight === null) {
437
+ return undefined;
438
+ }
439
+
440
+ // Already a number
441
+ if (typeof weight === 'number') {
442
+ if (weight >= 100 && weight <= 900) {
443
+ return weight;
444
+ }
445
+ return undefined;
446
+ }
447
+
448
+ return FONT_WEIGHT_MAP[weight];
449
+ }
450
+
451
+ /**
452
+ * Parse a CSS font-variant-numeric string into the protocol bitmask.
453
+ *
454
+ * Mutually exclusive categories use last-token-wins semantics to match CSS.
455
+ */
456
+ export function parseFontVariantNumeric(value: string | undefined): number {
457
+ if (typeof value !== 'string') {
458
+ return 0;
459
+ }
460
+
461
+ let mask = 0;
462
+
463
+ for (const token of value.split(/\s+/)) {
464
+ switch (token) {
465
+ case 'tabular-nums':
466
+ mask = (mask & ~FONT_VARIANT_NUMERIC_PROPORTIONAL_NUMS) | FONT_VARIANT_NUMERIC_TABULAR_NUMS;
467
+ break;
468
+ case 'proportional-nums':
469
+ mask = (mask & ~FONT_VARIANT_NUMERIC_TABULAR_NUMS) | FONT_VARIANT_NUMERIC_PROPORTIONAL_NUMS;
470
+ break;
471
+ case 'lining-nums':
472
+ mask = (mask & ~FONT_VARIANT_NUMERIC_OLDSTYLE_NUMS) | FONT_VARIANT_NUMERIC_LINING_NUMS;
473
+ break;
474
+ case 'oldstyle-nums':
475
+ mask = (mask & ~FONT_VARIANT_NUMERIC_LINING_NUMS) | FONT_VARIANT_NUMERIC_OLDSTYLE_NUMS;
476
+ break;
477
+ case 'slashed-zero':
478
+ mask |= FONT_VARIANT_NUMERIC_SLASHED_ZERO;
479
+ break;
480
+ case 'normal':
481
+ mask = 0;
482
+ break;
483
+ default:
484
+ break;
485
+ }
486
+ }
487
+
488
+ return mask;
489
+ }
490
+
491
+ /**
492
+ * Extract transform values from the transform array.
493
+ *
494
+ * @param transform - The transform array from styles
495
+ * @returns Object with extracted transform values
496
+ */
497
+ export function extractTransforms(
498
+ transform: ViewStyle['transform']
499
+ ): { x?: number; y?: number; scale?: number; rotate?: number } {
500
+ if (!transform || !Array.isArray(transform)) {
501
+ return {};
502
+ }
503
+
504
+ const result: { x?: number; y?: number; scale?: number; rotate?: number } = {};
505
+
506
+ for (const t of transform) {
507
+ if ('translateX' in t && typeof t.translateX === 'number') {
508
+ result.x = t.translateX;
509
+ } else if ('translateY' in t && typeof t.translateY === 'number') {
510
+ result.y = t.translateY;
511
+ } else if ('scale' in t && typeof t.scale === 'number') {
512
+ result.scale = t.scale;
513
+ } else if ('rotate' in t && typeof t.rotate === 'string') {
514
+ // Parse rotation - support deg and rad
515
+ const rotateStr = t.rotate.trim();
516
+ if (rotateStr.endsWith('deg')) {
517
+ const degrees = parseFloat(rotateStr.slice(0, -3));
518
+ if (isFinite(degrees)) {
519
+ result.rotate = (degrees * Math.PI) / 180;
520
+ }
521
+ } else if (rotateStr.endsWith('rad')) {
522
+ const radians = parseFloat(rotateStr.slice(0, -3));
523
+ if (isFinite(radians)) {
524
+ result.rotate = radians;
525
+ }
526
+ } else {
527
+ // Assume degrees if no unit
528
+ const degrees = parseFloat(rotateStr);
529
+ if (isFinite(degrees)) {
530
+ result.rotate = (degrees * Math.PI) / 180;
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ return result;
537
+ }
538
+
539
+ function parseLengthToken(token: string): number | undefined {
540
+ const trimmed = token.trim();
541
+ if (trimmed.length === 0) {
542
+ return undefined;
543
+ }
544
+
545
+ if (trimmed.endsWith('px')) {
546
+ const value = parseFloat(trimmed.slice(0, -2));
547
+ return isFinite(value) ? value : undefined;
548
+ }
549
+
550
+ const value = parseFloat(trimmed);
551
+ return isFinite(value) ? value : undefined;
552
+ }
553
+
554
+ function splitTopLevelTokens(input: string, separator: 'comma' | 'space'): string[] {
555
+ const tokens: string[] = [];
556
+ let current = '';
557
+ let depth = 0;
558
+
559
+ for (const char of input.trim()) {
560
+ if (char === '(') {
561
+ depth += 1;
562
+ current += char;
563
+ continue;
564
+ }
565
+
566
+ if (char === ')') {
567
+ depth = Math.max(0, depth - 1);
568
+ current += char;
569
+ continue;
570
+ }
571
+
572
+ const shouldSplit =
573
+ depth === 0 &&
574
+ (separator === 'comma' ? char === ',' : /\s/.test(char));
575
+ if (shouldSplit) {
576
+ const token = current.trim();
577
+ if (token.length > 0) {
578
+ tokens.push(token);
579
+ }
580
+ current = '';
581
+ continue;
582
+ }
583
+
584
+ current += char;
585
+ }
586
+
587
+ const token = current.trim();
588
+ if (token.length > 0) {
589
+ tokens.push(token);
590
+ }
591
+
592
+ return tokens;
593
+ }
594
+
595
+ function firstBoxShadowLayer(input: string): string {
596
+ let depth = 0;
597
+
598
+ for (let index = 0; index < input.length; index += 1) {
599
+ const char = input[index]!;
600
+ if (char === '(') {
601
+ depth += 1;
602
+ continue;
603
+ }
604
+ if (char === ')') {
605
+ depth = Math.max(0, depth - 1);
606
+ continue;
607
+ }
608
+ if (char === ',' && depth === 0) {
609
+ return input.slice(0, index).trim();
610
+ }
611
+ }
612
+
613
+ return input.trim();
614
+ }
615
+
616
+ function splitShadowColor(
617
+ color: ReturnType<typeof parseColor> | undefined,
618
+ ): { color?: ReturnType<typeof parseColor>; opacity?: number } {
619
+ if (!color) {
620
+ return {};
621
+ }
622
+
623
+ return {
624
+ color: { ...color, a: 255 },
625
+ opacity: color.a / 255,
626
+ };
627
+ }
628
+
629
+ function parseBoxShadow(value: string | undefined): {
630
+ offsetX: number;
631
+ offsetY: number;
632
+ radius: number;
633
+ color?: ReturnType<typeof parseColor>;
634
+ opacity?: number;
635
+ } | undefined {
636
+ if (typeof value !== 'string') {
637
+ return undefined;
638
+ }
639
+
640
+ const layer = firstBoxShadowLayer(value);
641
+ if (layer.length === 0 || layer === 'none') {
642
+ return undefined;
643
+ }
644
+
645
+ const tokens = splitTopLevelTokens(layer, 'space').filter((token) => token !== 'inset');
646
+ if (tokens.length < 2) {
647
+ return undefined;
648
+ }
649
+
650
+ let colorTokenIndex = -1;
651
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
652
+ if (parseColor(tokens[index]!)) {
653
+ colorTokenIndex = index;
654
+ break;
655
+ }
656
+ }
657
+
658
+ const color =
659
+ colorTokenIndex >= 0 ? parseColor(tokens[colorTokenIndex]!) ?? undefined : undefined;
660
+ const lengthTokens = tokens.filter((_, index) => index !== colorTokenIndex);
661
+ const lengths = lengthTokens
662
+ .map((token) => parseLengthToken(token))
663
+ .filter((token): token is number => typeof token === 'number');
664
+
665
+ if (lengths.length < 2) {
666
+ return undefined;
667
+ }
668
+
669
+ const normalizedColor = splitShadowColor(color);
670
+ return {
671
+ offsetX: lengths[0]!,
672
+ offsetY: lengths[1]!,
673
+ radius: Math.max(0, lengths[2] ?? 0),
674
+ color: normalizedColor.color,
675
+ opacity: normalizedColor.opacity,
676
+ };
677
+ }
678
+
679
+ function normalizeTransitionPropertyName(input: string): CanonicalTransitionProperty | undefined {
680
+ return TRANSITION_PROPERTY_ALIASES[input.trim()];
681
+ }
682
+
683
+ function normalizePositiveMs(value: unknown, fallback: number): number {
684
+ if (typeof value !== 'number' || !isFinite(value)) {
685
+ return fallback;
686
+ }
687
+ return Math.max(0, Math.round(value));
688
+ }
689
+
690
+ function normalizeSpringDuration(value: unknown): number | undefined {
691
+ if (value === undefined || value === null) {
692
+ return undefined;
693
+ }
694
+ return normalizePositiveMs(value, 0);
695
+ }
696
+
697
+ function normalizeBoolean(value: unknown, fallback: boolean): boolean {
698
+ if (typeof value !== 'boolean') {
699
+ return fallback;
700
+ }
701
+ return value;
702
+ }
703
+
704
+ function normalizeCubicBezierEasing(value: unknown): CubicBezierEasing | undefined {
705
+ if (!Array.isArray(value) || value.length !== 4) {
706
+ return undefined;
707
+ }
708
+
709
+ const [x1, y1, x2, y2] = value;
710
+ if (
711
+ typeof x1 !== 'number' || !isFinite(x1) ||
712
+ typeof y1 !== 'number' || !isFinite(y1) ||
713
+ typeof x2 !== 'number' || !isFinite(x2) ||
714
+ typeof y2 !== 'number' || !isFinite(y2)
715
+ ) {
716
+ return undefined;
717
+ }
718
+
719
+ return [x1, y1, x2, y2];
720
+ }
721
+
722
+ function normalizeTransitionEasing(easing: TransitionEasing | undefined): TransitionEasing {
723
+ if (typeof easing === 'string') {
724
+ return TRANSITION_EASING_ALIASES[easing] ?? TIMING_EASING_DEFAULT;
725
+ }
726
+
727
+ const cubicBezier = normalizeCubicBezierEasing(easing);
728
+ return cubicBezier ?? TIMING_EASING_DEFAULT;
729
+ }
730
+
731
+ function normalizeTransitionConfig(config: TransitionConfig | undefined): CanonicalTransitionConfig | undefined {
732
+ if (!config || config.type === 'none') {
733
+ return undefined;
734
+ }
735
+
736
+ if (config.type === 'timing') {
737
+ return {
738
+ type: 'timing',
739
+ duration: normalizePositiveMs(config.duration, TIMING_DURATION_DEFAULT_MS),
740
+ delay: normalizePositiveMs(config.delay, TIMING_DELAY_DEFAULT_MS),
741
+ easing: normalizeTransitionEasing(config.easing),
742
+ respectsReducedMotion: normalizeBoolean(config.respectsReducedMotion, true),
743
+ };
744
+ }
745
+
746
+ return {
747
+ type: 'spring',
748
+ duration: normalizeSpringDuration(config.duration),
749
+ delay: normalizePositiveMs(config.delay, SPRING_DELAY_DEFAULT_MS),
750
+ damping: typeof config.damping === 'number' && isFinite(config.damping) ? config.damping : SPRING_DAMPING_DEFAULT,
751
+ stiffness: typeof config.stiffness === 'number' && isFinite(config.stiffness) ? config.stiffness : SPRING_STIFFNESS_DEFAULT,
752
+ mass: typeof config.mass === 'number' && isFinite(config.mass) ? config.mass : SPRING_MASS_DEFAULT,
753
+ velocity: typeof config.velocity === 'number' && isFinite(config.velocity) ? config.velocity : SPRING_VELOCITY_DEFAULT,
754
+ respectsReducedMotion: normalizeBoolean(config.respectsReducedMotion, true),
755
+ };
756
+ }
757
+
758
+ function parseTimeToken(token: string): number | undefined {
759
+ const trimmed = token.trim();
760
+
761
+ if (trimmed.endsWith('ms')) {
762
+ const value = parseFloat(trimmed.slice(0, -2));
763
+ return isFinite(value) ? Math.max(0, Math.round(value)) : undefined;
764
+ }
765
+
766
+ if (trimmed.endsWith('s')) {
767
+ const value = parseFloat(trimmed.slice(0, -1));
768
+ return isFinite(value) ? Math.max(0, Math.round(value * 1000)) : undefined;
769
+ }
770
+
771
+ return undefined;
772
+ }
773
+
774
+ function parseCubicBezierFunction(token: string): CubicBezierEasing | undefined {
775
+ const match = /^cubic-bezier\((.+)\)$/i.exec(token.trim());
776
+ if (!match) {
777
+ return undefined;
778
+ }
779
+
780
+ const values = match[1]
781
+ .split(',')
782
+ .map((value) => parseFloat(value.trim()));
783
+
784
+ return normalizeCubicBezierEasing(values);
785
+ }
786
+
787
+ type ParsedSpringConfig = {
788
+ damping?: number;
789
+ stiffness?: number;
790
+ mass?: number;
791
+ velocity?: number;
792
+ };
793
+
794
+ function parseSpringFunction(token: string): ParsedSpringConfig | undefined {
795
+ const match = /^spring\((.*)\)$/i.exec(token.trim());
796
+ if (!match) {
797
+ return undefined;
798
+ }
799
+
800
+ const params: Record<string, number> = {};
801
+ for (const entry of match[1].split(',')) {
802
+ const [rawKey, rawValue] = entry.split(':');
803
+ if (!rawKey || !rawValue) {
804
+ continue;
805
+ }
806
+ const key = rawKey.trim();
807
+ const value = parseFloat(rawValue.trim());
808
+ if (isFinite(value)) {
809
+ params[key] = value;
810
+ }
811
+ }
812
+
813
+ return {
814
+ damping: params.damping,
815
+ stiffness: params.stiffness,
816
+ mass: params.mass,
817
+ velocity: params.velocity,
818
+ };
819
+ }
820
+
821
+ function splitTransitionEntries(input: string): string[] {
822
+ return splitTopLevelTokens(input, 'comma');
823
+ }
824
+
825
+ function tokenizeTransitionEntry(entry: string): string[] {
826
+ return splitTopLevelTokens(entry, 'space');
827
+ }
828
+
829
+ function parseTransitionStringEntry(entry: string): [CanonicalTransitionProperty, CanonicalTransitionConfig] | undefined {
830
+ const tokens = tokenizeTransitionEntry(entry);
831
+ if (tokens.length < 2) {
832
+ return undefined;
833
+ }
834
+
835
+ const property = normalizeTransitionPropertyName(tokens[0]);
836
+ if (!property) {
837
+ return undefined;
838
+ }
839
+
840
+ const initialDuration = parseTimeToken(tokens[1]);
841
+ const initialSpring = parseSpringFunction(tokens[1]);
842
+ if (initialDuration === undefined && !initialSpring) {
843
+ return undefined;
844
+ }
845
+
846
+ let duration = initialDuration;
847
+ let delay = 0;
848
+ let easing: TransitionEasing = TIMING_EASING_DEFAULT;
849
+ let config: TransitionConfig = initialSpring
850
+ ? {
851
+ type: 'spring',
852
+ duration,
853
+ damping: initialSpring.damping,
854
+ stiffness: initialSpring.stiffness,
855
+ mass: initialSpring.mass,
856
+ velocity: initialSpring.velocity,
857
+ }
858
+ : {
859
+ type: 'timing',
860
+ duration: duration ?? TIMING_DURATION_DEFAULT_MS,
861
+ easing,
862
+ };
863
+
864
+ for (const token of tokens.slice(2)) {
865
+ const spring = parseSpringFunction(token);
866
+ if (spring) {
867
+ config = {
868
+ type: 'spring',
869
+ duration,
870
+ delay,
871
+ damping: typeof spring.damping === 'number' ? spring.damping : undefined,
872
+ stiffness: typeof spring.stiffness === 'number' ? spring.stiffness : undefined,
873
+ mass: typeof spring.mass === 'number' ? spring.mass : undefined,
874
+ velocity: typeof spring.velocity === 'number' ? spring.velocity : undefined,
875
+ };
876
+ continue;
877
+ }
878
+
879
+ const cubicBezier = parseCubicBezierFunction(token);
880
+ if (cubicBezier) {
881
+ easing = cubicBezier;
882
+ if (config.type === 'timing') {
883
+ config = { ...config, easing };
884
+ }
885
+ continue;
886
+ }
887
+
888
+ const time = parseTimeToken(token);
889
+ if (time !== undefined) {
890
+ if (duration === undefined) {
891
+ duration = time;
892
+ if (config.type === 'timing') {
893
+ config = { ...config, duration };
894
+ } else {
895
+ config = { ...config, duration };
896
+ }
897
+ } else {
898
+ delay = time;
899
+ config = { ...config, delay };
900
+ }
901
+ continue;
902
+ }
903
+
904
+ const easingName = TRANSITION_EASING_ALIASES[token];
905
+ if (easingName) {
906
+ easing = easingName;
907
+ if (config.type === 'timing') {
908
+ config = { ...config, easing };
909
+ }
910
+ }
911
+ }
912
+
913
+ const normalized = normalizeTransitionConfig(config);
914
+ return normalized ? [property, normalized] : undefined;
915
+ }
916
+
917
+ /**
918
+ * Normalize transition metadata from the style object.
919
+ *
920
+ * The normalized transition map intentionally lives outside CanonicalStyle so
921
+ * style diffing and protocol emission can treat transition metadata separately.
922
+ */
923
+ export function normalizeTransition(transition: StyleTransition | undefined): CanonicalTransitionMap {
924
+ if (!transition) {
925
+ return {};
926
+ }
927
+
928
+ if (typeof transition === 'string') {
929
+ const trimmed = transition.trim();
930
+ if (trimmed.length === 0 || trimmed === 'none') {
931
+ return {};
932
+ }
933
+
934
+ const normalized: CanonicalTransitionMap = {};
935
+ for (const entry of splitTransitionEntries(trimmed)) {
936
+ const parsed = parseTransitionStringEntry(entry);
937
+ if (!parsed) {
938
+ continue;
939
+ }
940
+ const [property, config] = parsed;
941
+ normalized[property] = config;
942
+ }
943
+ return normalized;
944
+ }
945
+
946
+ const normalized: CanonicalTransitionMap = {};
947
+ for (const [propertyName, config] of Object.entries(transition)) {
948
+ const property = normalizeTransitionPropertyName(propertyName);
949
+ if (!property) {
950
+ continue;
951
+ }
952
+
953
+ const normalizedConfig = normalizeTransitionConfig(config);
954
+ if (normalizedConfig) {
955
+ normalized[property] = normalizedConfig;
956
+ }
957
+ }
958
+
959
+ return normalized;
960
+ }
961
+
962
+ /**
963
+ * Compare two normalized transition maps.
964
+ */
965
+ export function transitionsEqual(a: CanonicalTransitionMap, b: CanonicalTransitionMap): boolean {
966
+ if (a === b) {
967
+ return true;
968
+ }
969
+
970
+ const keysA = Object.keys(a) as CanonicalTransitionProperty[];
971
+ const keysB = Object.keys(b) as CanonicalTransitionProperty[];
972
+
973
+ if (keysA.length !== keysB.length) {
974
+ return false;
975
+ }
976
+
977
+ for (const key of keysA) {
978
+ const valA = a[key];
979
+ const valB = b[key];
980
+
981
+ if (!valA || !valB || valA.type !== valB.type) {
982
+ return false;
983
+ }
984
+
985
+ if (valA.delay !== valB.delay || valA.respectsReducedMotion !== valB.respectsReducedMotion) {
986
+ return false;
987
+ }
988
+
989
+ if (valA.type === 'timing' && valB.type === 'timing') {
990
+ if (valA.duration !== valB.duration) {
991
+ return false;
992
+ }
993
+
994
+ const easingA = valA.easing;
995
+ const easingB = valB.easing;
996
+ if (typeof easingA === 'string' || typeof easingB === 'string') {
997
+ if (easingA !== easingB) {
998
+ return false;
999
+ }
1000
+ } else if (
1001
+ easingA[0] !== easingB[0] ||
1002
+ easingA[1] !== easingB[1] ||
1003
+ easingA[2] !== easingB[2] ||
1004
+ easingA[3] !== easingB[3]
1005
+ ) {
1006
+ return false;
1007
+ }
1008
+ continue;
1009
+ }
1010
+
1011
+ if (valA.type === 'spring' && valB.type === 'spring') {
1012
+ if (
1013
+ valA.duration !== valB.duration ||
1014
+ valA.damping !== valB.damping ||
1015
+ valA.stiffness !== valB.stiffness ||
1016
+ valA.mass !== valB.mass ||
1017
+ valA.velocity !== valB.velocity
1018
+ ) {
1019
+ return false;
1020
+ }
1021
+ continue;
1022
+ }
1023
+
1024
+ return false;
1025
+ }
1026
+
1027
+ return true;
1028
+ }
1029
+
1030
+ /**
1031
+ * Normalize a style object into canonical internal format.
1032
+ *
1033
+ * @param style - The user-provided style object
1034
+ * @param defaultFlexDirection - The default flex direction for this element type
1035
+ * @returns The normalized canonical style
1036
+ */
1037
+ export function normalizeStyle(
1038
+ style: FullStyle | undefined,
1039
+ defaultFlexDirection: FlexDirection = 'row',
1040
+ options: NormalizeStyleOptions = {},
1041
+ ): CanonicalStyle {
1042
+ if (!style) {
1043
+ return {};
1044
+ }
1045
+
1046
+ const canonical: CanonicalStyle = {};
1047
+ const styleRecord = style as Record<string, unknown>;
1048
+ const explicitDirection = parseExplicitDirection(style.direction);
1049
+ const resolvedDirection: ResolvedDirection =
1050
+ explicitDirection === 'ltr' || explicitDirection === 'rtl'
1051
+ ? explicitDirection
1052
+ : options.resolvedDirection ?? 'ltr';
1053
+ const explicitWritingMode = parseWritingMode(style.writingMode);
1054
+ const resolvedWritingMode = explicitWritingMode ?? options.resolvedWritingMode ?? 'horizontal-tb';
1055
+
1056
+ if (options.includeInheritedValues === true || explicitDirection === 'ltr' || explicitDirection === 'rtl') {
1057
+ canonical.direction = resolvedDirection;
1058
+ }
1059
+ if (options.includeInheritedValues === true || explicitWritingMode !== undefined) {
1060
+ canonical.writingMode = resolvedWritingMode;
1061
+ }
1062
+
1063
+ // ==========================================================================
1064
+ // Dimensions
1065
+ // ==========================================================================
1066
+
1067
+ for (const key of BOX_DIMENSION_KEYS) {
1068
+ setDimension(canonical, key, style[key]);
1069
+ }
1070
+
1071
+ // ==========================================================================
1072
+ // Padding (shorthand first, then specific overrides)
1073
+ // ==========================================================================
1074
+
1075
+ // Handle padding shorthand
1076
+ const padding = parseDimension(style.padding);
1077
+ if (padding) {
1078
+ setDimensionEdges(canonical, PADDING_EDGE_KEYS, padding);
1079
+ }
1080
+
1081
+ // Handle paddingVertical/paddingHorizontal
1082
+ const paddingVertical = parseDimension(style.paddingVertical);
1083
+ if (paddingVertical) {
1084
+ setDimensionValue(canonical, PADDING_KEYS.top, paddingVertical);
1085
+ setDimensionValue(canonical, PADDING_KEYS.bottom, paddingVertical);
1086
+ }
1087
+ const paddingHorizontal = parseDimension(style.paddingHorizontal);
1088
+ if (paddingHorizontal) {
1089
+ setDimensionValue(canonical, PADDING_KEYS.right, paddingHorizontal);
1090
+ setDimensionValue(canonical, PADDING_KEYS.left, paddingHorizontal);
1091
+ }
1092
+
1093
+ const paddingStart = parseDimension(
1094
+ (style.paddingStart ?? style.paddingInlineStart ?? styleRecord['padding-inline-start']) as
1095
+ | DimensionInput
1096
+ | undefined,
1097
+ );
1098
+ if (paddingStart) {
1099
+ assignPaddingEdge(canonical, inlineStartEdge(resolvedDirection, resolvedWritingMode), paddingStart);
1100
+ }
1101
+
1102
+ const paddingEnd = parseDimension(
1103
+ (style.paddingEnd ?? style.paddingInlineEnd ?? styleRecord['padding-inline-end']) as
1104
+ | DimensionInput
1105
+ | undefined,
1106
+ );
1107
+ if (paddingEnd) {
1108
+ assignPaddingEdge(canonical, inlineEndEdge(resolvedDirection, resolvedWritingMode), paddingEnd);
1109
+ }
1110
+
1111
+ // Handle specific padding (highest precedence)
1112
+ for (const key of PADDING_EDGE_KEYS) {
1113
+ setDimension(canonical, key, style[key]);
1114
+ }
1115
+
1116
+ // ==========================================================================
1117
+ // Margin (shorthand first, then specific overrides)
1118
+ // ==========================================================================
1119
+
1120
+ // Handle margin shorthand
1121
+ const margin = parseDimension(style.margin);
1122
+ if (margin) {
1123
+ setDimensionEdges(canonical, MARGIN_EDGE_KEYS, margin);
1124
+ }
1125
+
1126
+ // Handle marginVertical/marginHorizontal
1127
+ const marginVertical = parseDimension(style.marginVertical);
1128
+ if (marginVertical) {
1129
+ setDimensionValue(canonical, MARGIN_KEYS.top, marginVertical);
1130
+ setDimensionValue(canonical, MARGIN_KEYS.bottom, marginVertical);
1131
+ }
1132
+ const marginHorizontal = parseDimension(style.marginHorizontal);
1133
+ if (marginHorizontal) {
1134
+ setDimensionValue(canonical, MARGIN_KEYS.right, marginHorizontal);
1135
+ setDimensionValue(canonical, MARGIN_KEYS.left, marginHorizontal);
1136
+ }
1137
+
1138
+ const marginStart = parseDimension(
1139
+ (style.marginStart ?? style.marginInlineStart ?? styleRecord['margin-inline-start']) as
1140
+ | DimensionInput
1141
+ | undefined,
1142
+ );
1143
+ if (marginStart) {
1144
+ assignMarginEdge(canonical, inlineStartEdge(resolvedDirection, resolvedWritingMode), marginStart);
1145
+ }
1146
+
1147
+ const marginEnd = parseDimension(
1148
+ (style.marginEnd ?? style.marginInlineEnd ?? styleRecord['margin-inline-end']) as
1149
+ | DimensionInput
1150
+ | undefined,
1151
+ );
1152
+ if (marginEnd) {
1153
+ assignMarginEdge(canonical, inlineEndEdge(resolvedDirection, resolvedWritingMode), marginEnd);
1154
+ }
1155
+
1156
+ // Handle specific margin (highest precedence)
1157
+ for (const key of MARGIN_EDGE_KEYS) {
1158
+ setDimension(canonical, key, style[key]);
1159
+ }
1160
+
1161
+ // ==========================================================================
1162
+ // Flexbox
1163
+ // ==========================================================================
1164
+
1165
+ // Handle 'flex' shorthand
1166
+ if (typeof style.flex === 'number' && isFinite(style.flex)) {
1167
+ // CSS spec: flex: N means flexGrow: N, flexShrink: 1, flexBasis: 0
1168
+ canonical.flexGrow = style.flex;
1169
+ canonical.flexShrink = 1;
1170
+ canonical.flexBasis = { type: 'points', value: 0 };
1171
+ }
1172
+
1173
+ // Specific flex properties override shorthand
1174
+ assignNumberStyles(canonical, style, FLEX_NUMBER_ASSIGNMENTS);
1175
+
1176
+ setDimension(canonical, 'flexBasis', style.flexBasis);
1177
+
1178
+ // Flex direction - apply default if not specified
1179
+ if (style.flexDirection !== undefined) {
1180
+ canonical.flexDirection = style.flexDirection;
1181
+ } else if (defaultFlexDirection !== 'row') {
1182
+ // Only include if non-web default
1183
+ canonical.flexDirection = defaultFlexDirection;
1184
+ }
1185
+
1186
+ assignDefinedStyles(canonical, style, FLEX_DIRECT_ASSIGNMENTS);
1187
+
1188
+ // Gap
1189
+ if (typeof style.gap === 'number' && isFinite(style.gap)) {
1190
+ canonical.rowGap = style.gap;
1191
+ canonical.columnGap = style.gap;
1192
+ }
1193
+ assignNumberStyles(canonical, style, GAP_NUMBER_ASSIGNMENTS);
1194
+
1195
+ // ==========================================================================
1196
+ // Position
1197
+ // ==========================================================================
1198
+
1199
+ if (style.position !== undefined) {
1200
+ canonical.positionType = style.position;
1201
+ }
1202
+
1203
+ setDimension(canonical, 'top', style.top);
1204
+
1205
+ const start = parseDimension(
1206
+ (style.start ?? style.insetInlineStart ?? styleRecord['inset-inline-start']) as
1207
+ | DimensionInput
1208
+ | undefined,
1209
+ );
1210
+ if (start) {
1211
+ assignInsetEdge(canonical, inlineStartEdge(resolvedDirection, resolvedWritingMode), start);
1212
+ }
1213
+
1214
+ const end = parseDimension(
1215
+ (style.end ?? style.insetInlineEnd ?? styleRecord['inset-inline-end']) as
1216
+ | DimensionInput
1217
+ | undefined,
1218
+ );
1219
+ if (end) {
1220
+ assignInsetEdge(canonical, inlineEndEdge(resolvedDirection, resolvedWritingMode), end);
1221
+ }
1222
+
1223
+ for (const key of POSITION_TRAILING_EDGE_KEYS) {
1224
+ setDimension(canonical, key, style[key]);
1225
+ }
1226
+
1227
+ if (typeof style.zIndex === 'number' && isFinite(style.zIndex)) {
1228
+ canonical.zIndex = style.zIndex;
1229
+ }
1230
+
1231
+ // ==========================================================================
1232
+ // Transform
1233
+ // ==========================================================================
1234
+
1235
+ if (style.transform) {
1236
+ const transforms = extractTransforms(style.transform);
1237
+ if (transforms.x !== undefined) canonical.transformX = transforms.x;
1238
+ if (transforms.y !== undefined) canonical.transformY = transforms.y;
1239
+ if (transforms.scale !== undefined) canonical.transformScale = transforms.scale;
1240
+ if (transforms.rotate !== undefined) canonical.transformRotate = transforms.rotate;
1241
+ }
1242
+
1243
+ // ==========================================================================
1244
+ // Appearance
1245
+ // ==========================================================================
1246
+
1247
+ if (style.display === 'flex' || style.display === 'grid' || style.display === 'none') {
1248
+ canonical.display = style.display;
1249
+ }
1250
+
1251
+ if (style.backgroundColor) {
1252
+ const color = parseColor(style.backgroundColor);
1253
+ if (color) canonical.backgroundColor = color;
1254
+ }
1255
+
1256
+ assignNumberStyles(canonical, style, APPEARANCE_NUMBER_ASSIGNMENTS);
1257
+
1258
+ if (style.borderColor) {
1259
+ const color = parseColor(style.borderColor);
1260
+ if (color) canonical.borderColor = color;
1261
+ }
1262
+ // Per-side border colors (ENG-22085).
1263
+ if (style.borderTopColor) {
1264
+ const color = parseColor(style.borderTopColor);
1265
+ if (color) canonical.borderTopColor = color;
1266
+ }
1267
+ if (style.borderRightColor) {
1268
+ const color = parseColor(style.borderRightColor);
1269
+ if (color) canonical.borderRightColor = color;
1270
+ }
1271
+ if (style.borderBottomColor) {
1272
+ const color = parseColor(style.borderBottomColor);
1273
+ if (color) canonical.borderBottomColor = color;
1274
+ }
1275
+ if (style.borderLeftColor) {
1276
+ const color = parseColor(style.borderLeftColor);
1277
+ if (color) canonical.borderLeftColor = color;
1278
+ }
1279
+
1280
+ if (style.boxShadow) {
1281
+ const boxShadow = parseBoxShadow(style.boxShadow);
1282
+ if (boxShadow?.color) {
1283
+ canonical.shadowColor = boxShadow.color;
1284
+ }
1285
+ if (boxShadow?.opacity !== undefined) {
1286
+ canonical.shadowOpacity = boxShadow.opacity;
1287
+ }
1288
+ if (boxShadow) {
1289
+ canonical.shadowOffsetX = boxShadow.offsetX;
1290
+ canonical.shadowOffsetY = boxShadow.offsetY;
1291
+ canonical.shadowRadius = boxShadow.radius;
1292
+ }
1293
+ }
1294
+
1295
+ if (style.shadowColor) {
1296
+ const normalizedShadow = splitShadowColor(parseColor(style.shadowColor));
1297
+ if (normalizedShadow.color) {
1298
+ canonical.shadowColor = normalizedShadow.color;
1299
+ }
1300
+ if (normalizedShadow.opacity !== undefined) {
1301
+ canonical.shadowOpacity = normalizedShadow.opacity;
1302
+ }
1303
+ }
1304
+
1305
+ if (
1306
+ typeof style.shadowOffset === 'object' &&
1307
+ style.shadowOffset !== null &&
1308
+ typeof style.shadowOffset.width === 'number' &&
1309
+ isFinite(style.shadowOffset.width) &&
1310
+ typeof style.shadowOffset.height === 'number' &&
1311
+ isFinite(style.shadowOffset.height)
1312
+ ) {
1313
+ canonical.shadowOffsetX = style.shadowOffset.width;
1314
+ canonical.shadowOffsetY = style.shadowOffset.height;
1315
+ }
1316
+
1317
+ assignNumberStyles(canonical, style, SHADOW_NUMBER_ASSIGNMENTS);
1318
+
1319
+ if (style.overflow !== undefined) {
1320
+ canonical.overflow = style.overflow;
1321
+ }
1322
+
1323
+ // ==========================================================================
1324
+ // Typography
1325
+ // ==========================================================================
1326
+
1327
+ if (style.fontFamily !== undefined) {
1328
+ const fontFamily = resolveFontFamilyId(style.fontFamily);
1329
+ if (fontFamily !== undefined) {
1330
+ canonical.fontFamily = fontFamily;
1331
+ }
1332
+ }
1333
+
1334
+ assignNumberStyles(canonical, style, TYPOGRAPHY_NUMBER_ASSIGNMENTS);
1335
+
1336
+ if (style.color) {
1337
+ const color = parseColor(style.color);
1338
+ if (color) canonical.textColor = color;
1339
+ }
1340
+
1341
+ const fontWeight = parseFontWeight(style.fontWeight);
1342
+ if (fontWeight !== undefined) canonical.fontWeight = fontWeight;
1343
+
1344
+ if (style.fontStyle === 'normal' || style.fontStyle === 'italic') {
1345
+ canonical.fontStyle = style.fontStyle;
1346
+ }
1347
+
1348
+ if (typeof style.fontVariantNumeric === 'string') {
1349
+ const fontVariantNumeric = parseFontVariantNumeric(style.fontVariantNumeric);
1350
+ if (fontVariantNumeric !== 0) {
1351
+ canonical.fontVariantNumeric = fontVariantNumeric;
1352
+ }
1353
+ }
1354
+
1355
+ if (style.textAlign !== undefined) {
1356
+ canonical.textAlign = style.textAlign;
1357
+ }
1358
+
1359
+ if (
1360
+ style.textDecorationLine === 'none' ||
1361
+ style.textDecorationLine === 'underline' ||
1362
+ style.textDecorationLine === 'line-through' ||
1363
+ style.textDecorationLine === 'underline line-through'
1364
+ ) {
1365
+ canonical.textDecorationLine = style.textDecorationLine;
1366
+ }
1367
+
1368
+ if (style.ellipsizeMode !== undefined) {
1369
+ canonical.ellipsizeMode = style.ellipsizeMode;
1370
+ }
1371
+
1372
+ return canonical;
1373
+ }
1374
+
1375
+ /**
1376
+ * Compare two canonical styles and return true if they are equal.
1377
+ *
1378
+ * @param a - First style
1379
+ * @param b - Second style
1380
+ * @returns True if styles are equal
1381
+ */
1382
+ export function stylesEqual(a: CanonicalStyle, b: CanonicalStyle): boolean {
1383
+ // Quick reference check
1384
+ if (a === b) return true;
1385
+
1386
+ // Compare all keys
1387
+ const keysA = Object.keys(a) as (keyof CanonicalStyle)[];
1388
+ const keysB = Object.keys(b) as (keyof CanonicalStyle)[];
1389
+
1390
+ if (keysA.length !== keysB.length) return false;
1391
+
1392
+ for (const key of keysA) {
1393
+ const valA = a[key];
1394
+ const valB = b[key];
1395
+
1396
+ if (valA === valB) continue;
1397
+
1398
+ // Deep compare for objects
1399
+ if (typeof valA === 'object' && typeof valB === 'object' && valA !== null && valB !== null) {
1400
+ // Compare dimension values
1401
+ if ('type' in valA && 'type' in valB) {
1402
+ if (valA.type !== valB.type) return false;
1403
+ if ('value' in valA && 'value' in valB) {
1404
+ if (valA.value !== valB.value) return false;
1405
+ }
1406
+ continue;
1407
+ }
1408
+ // Compare RGBA colors
1409
+ if ('r' in valA && 'r' in valB) {
1410
+ if (
1411
+ valA.r !== valB.r ||
1412
+ valA.g !== valB.g ||
1413
+ valA.b !== valB.b ||
1414
+ valA.a !== valB.a
1415
+ ) {
1416
+ return false;
1417
+ }
1418
+ continue;
1419
+ }
1420
+ }
1421
+
1422
+ return false;
1423
+ }
1424
+
1425
+ return true;
1426
+ }