flexily 0.0.1 → 0.2.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/classic/layout.d.ts +57 -0
  4. package/dist/classic/layout.d.ts.map +1 -0
  5. package/dist/classic/layout.js +1558 -0
  6. package/dist/classic/layout.js.map +1 -0
  7. package/dist/classic/node.d.ts +648 -0
  8. package/dist/classic/node.d.ts.map +1 -0
  9. package/dist/classic/node.js +1002 -0
  10. package/dist/classic/node.js.map +1 -0
  11. package/dist/constants.d.ts +58 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/constants.js +70 -0
  14. package/dist/constants.js.map +1 -0
  15. package/dist/index-classic.d.ts +30 -0
  16. package/dist/index-classic.d.ts.map +1 -0
  17. package/dist/index-classic.js +57 -0
  18. package/dist/index-classic.js.map +1 -0
  19. package/dist/index.d.ts +30 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +57 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/layout-flex-lines.d.ts +77 -0
  24. package/dist/layout-flex-lines.d.ts.map +1 -0
  25. package/dist/layout-flex-lines.js +317 -0
  26. package/dist/layout-flex-lines.js.map +1 -0
  27. package/dist/layout-helpers.d.ts +48 -0
  28. package/dist/layout-helpers.d.ts.map +1 -0
  29. package/dist/layout-helpers.js +108 -0
  30. package/dist/layout-helpers.js.map +1 -0
  31. package/dist/layout-measure.d.ts +25 -0
  32. package/dist/layout-measure.d.ts.map +1 -0
  33. package/dist/layout-measure.js +231 -0
  34. package/dist/layout-measure.js.map +1 -0
  35. package/dist/layout-stats.d.ts +19 -0
  36. package/dist/layout-stats.d.ts.map +1 -0
  37. package/dist/layout-stats.js +37 -0
  38. package/dist/layout-stats.js.map +1 -0
  39. package/dist/layout-traversal.d.ts +28 -0
  40. package/dist/layout-traversal.d.ts.map +1 -0
  41. package/dist/layout-traversal.js +65 -0
  42. package/dist/layout-traversal.js.map +1 -0
  43. package/dist/layout-zero.d.ts +26 -0
  44. package/dist/layout-zero.d.ts.map +1 -0
  45. package/dist/layout-zero.js +1601 -0
  46. package/dist/layout-zero.js.map +1 -0
  47. package/dist/logger.d.ts +14 -0
  48. package/dist/logger.d.ts.map +1 -0
  49. package/dist/logger.js +61 -0
  50. package/dist/logger.js.map +1 -0
  51. package/dist/node-zero.d.ts +702 -0
  52. package/dist/node-zero.d.ts.map +1 -0
  53. package/dist/node-zero.js +1268 -0
  54. package/dist/node-zero.js.map +1 -0
  55. package/dist/testing.d.ts +69 -0
  56. package/dist/testing.d.ts.map +1 -0
  57. package/dist/testing.js +179 -0
  58. package/dist/testing.js.map +1 -0
  59. package/dist/trace.d.ts +74 -0
  60. package/dist/trace.d.ts.map +1 -0
  61. package/dist/trace.js +191 -0
  62. package/dist/trace.js.map +1 -0
  63. package/dist/types.d.ts +170 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/types.js +43 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/utils.d.ts +41 -0
  68. package/dist/utils.d.ts.map +1 -0
  69. package/dist/utils.js +197 -0
  70. package/dist/utils.js.map +1 -0
  71. package/package.json +58 -3
  72. package/src/CLAUDE.md +512 -0
  73. package/src/beorn-logger.d.ts +10 -0
  74. package/src/classic/layout.ts +1783 -0
  75. package/src/classic/node.ts +1121 -0
  76. package/src/constants.ts +81 -0
  77. package/src/index-classic.ts +110 -0
  78. package/src/index.ts +110 -0
  79. package/src/layout-flex-lines.ts +346 -0
  80. package/src/layout-helpers.ts +140 -0
  81. package/src/layout-measure.ts +259 -0
  82. package/src/layout-stats.ts +43 -0
  83. package/src/layout-traversal.ts +70 -0
  84. package/src/layout-zero.ts +1792 -0
  85. package/src/logger.ts +67 -0
  86. package/src/node-zero.ts +1412 -0
  87. package/src/testing.ts +209 -0
  88. package/src/trace.ts +252 -0
  89. package/src/types.ts +229 -0
  90. package/src/utils.ts +217 -0
@@ -0,0 +1,1783 @@
1
+ /**
2
+ * Flexily Layout Algorithm
3
+ *
4
+ * Core flexbox layout computation extracted from node.ts.
5
+ * Based on Planning-nl/flexbox.js reference implementation.
6
+ */
7
+
8
+ import * as C from "../constants.js"
9
+ import type { Node } from "./node.js"
10
+ import type { Value } from "../types.js"
11
+ import { resolveValue, applyMinMax } from "../utils.js"
12
+ import { log } from "../logger.js"
13
+
14
+ // ============================================================================
15
+ // Helper Functions
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Check if flex direction is row-oriented (horizontal main axis).
20
+ */
21
+ export function isRowDirection(flexDirection: number): boolean {
22
+ return flexDirection === C.FLEX_DIRECTION_ROW || flexDirection === C.FLEX_DIRECTION_ROW_REVERSE
23
+ }
24
+
25
+ /**
26
+ * Check if flex direction is reversed.
27
+ */
28
+ export function isReverseDirection(flexDirection: number): boolean {
29
+ return flexDirection === C.FLEX_DIRECTION_ROW_REVERSE || flexDirection === C.FLEX_DIRECTION_COLUMN_REVERSE
30
+ }
31
+
32
+ /**
33
+ * Get the logical edge value (START/END) for a given physical index.
34
+ * Returns undefined if no logical value applies to this physical edge.
35
+ *
36
+ * The mapping depends on flex direction and text direction:
37
+ * - Row LTR: START→left, END→right (swapped if reverse)
38
+ * - Row RTL: START→right, END→left (swapped if reverse)
39
+ * - Column: START→top, END→bottom (swapped if reverse)
40
+ */
41
+ function getLogicalEdgeValue(
42
+ arr: [Value, Value, Value, Value, Value, Value],
43
+ physicalIndex: number,
44
+ _flexDirection: number,
45
+ direction: number = C.DIRECTION_LTR,
46
+ ): Value | undefined {
47
+ const isRTL = direction === C.DIRECTION_RTL
48
+
49
+ // START/END always map to left/right (inline direction)
50
+ if (physicalIndex === 0) {
51
+ return isRTL ? arr[5] : arr[4] // Left: START (LTR) or END (RTL)
52
+ } else if (physicalIndex === 2) {
53
+ return isRTL ? arr[4] : arr[5] // Right: END (LTR) or START (RTL)
54
+ }
55
+ return undefined
56
+ }
57
+
58
+ /**
59
+ * Resolve logical (START/END) margins/padding to physical values.
60
+ * EDGE_START/EDGE_END always resolve along the inline (horizontal) axis:
61
+ * - LTR: START→left, END→right
62
+ * - RTL: START→right, END→left
63
+ *
64
+ * Physical edges (LEFT/RIGHT/TOP/BOTTOM) are used directly.
65
+ * When both physical and logical are set, logical takes precedence.
66
+ */
67
+ export function resolveEdgeValue(
68
+ arr: [Value, Value, Value, Value, Value, Value],
69
+ physicalIndex: number, // 0=left, 1=top, 2=right, 3=bottom
70
+ flexDirection: number,
71
+ availableSize: number,
72
+ direction: number = C.DIRECTION_LTR,
73
+ ): number {
74
+ const logicalValue = getLogicalEdgeValue(arr, physicalIndex, flexDirection, direction)
75
+
76
+ // Logical takes precedence if defined
77
+ if (logicalValue && logicalValue.unit !== C.UNIT_UNDEFINED) {
78
+ return resolveValue(logicalValue, availableSize)
79
+ }
80
+
81
+ // Fall back to physical
82
+ return resolveValue(arr[physicalIndex]!, availableSize)
83
+ }
84
+
85
+ /**
86
+ * Check if a logical edge margin is set to auto.
87
+ */
88
+ export function isEdgeAuto(
89
+ arr: [Value, Value, Value, Value, Value, Value],
90
+ physicalIndex: number,
91
+ flexDirection: number,
92
+ direction: number = C.DIRECTION_LTR,
93
+ ): boolean {
94
+ const logicalValue = getLogicalEdgeValue(arr, physicalIndex, flexDirection, direction)
95
+
96
+ // Check logical first
97
+ if (logicalValue && logicalValue.unit !== C.UNIT_UNDEFINED) {
98
+ return logicalValue.unit === C.UNIT_AUTO
99
+ }
100
+
101
+ // Fall back to physical
102
+ return arr[physicalIndex]!.unit === C.UNIT_AUTO
103
+ }
104
+
105
+ /**
106
+ * Resolve logical (START/END) border widths to physical values.
107
+ * Border values are plain numbers (always points), so resolution is simpler
108
+ * than for margin/padding. Uses NaN as the "not set" sentinel for logical slots.
109
+ * When both physical and logical are set, logical takes precedence.
110
+ */
111
+ export function resolveEdgeBorderValue(
112
+ arr: [number, number, number, number, number, number],
113
+ physicalIndex: number, // 0=left, 1=top, 2=right, 3=bottom
114
+ _flexDirection: number,
115
+ direction: number = C.DIRECTION_LTR,
116
+ ): number {
117
+ const isRTL = direction === C.DIRECTION_RTL
118
+
119
+ // START/END always map to left/right (inline direction)
120
+ let logicalSlot: number | undefined
121
+ if (physicalIndex === 0) logicalSlot = isRTL ? 5 : 4
122
+ else if (physicalIndex === 2) logicalSlot = isRTL ? 4 : 5
123
+
124
+ // Logical takes precedence if set (NaN = not set)
125
+ if (logicalSlot !== undefined && !Number.isNaN(arr[logicalSlot])) {
126
+ return arr[logicalSlot]
127
+ }
128
+ return arr[physicalIndex]
129
+ }
130
+
131
+ export function markSubtreeLayoutSeen(node: Node): void {
132
+ for (const child of node.children) {
133
+ ;(child as Node)["_hasNewLayout"] = true
134
+ markSubtreeLayoutSeen(child)
135
+ }
136
+ }
137
+
138
+ export function countNodes(node: Node): number {
139
+ let count = 1
140
+ for (const child of node.children) {
141
+ count += countNodes(child)
142
+ }
143
+ return count
144
+ }
145
+
146
+ // ============================================================================
147
+ // Layout Algorithm
148
+ // Based on Planning-nl/flexbox.js reference implementation
149
+ // ============================================================================
150
+
151
+ /**
152
+ * Epsilon value for floating point comparisons in flex distribution.
153
+ * Used to determine when remaining space is negligible and iteration should stop.
154
+ */
155
+ const EPSILON_FLOAT = 0.001
156
+
157
+ /**
158
+ * Child layout information for flex distribution.
159
+ */
160
+ interface ChildLayout {
161
+ node: Node
162
+ mainSize: number
163
+ baseSize: number // Original base size before flex distribution (for weighted shrink)
164
+ mainMargin: number // Total main-axis margin (non-auto only)
165
+ flexGrow: number
166
+ flexShrink: number
167
+ minMain: number
168
+ maxMain: number
169
+ // Auto margin tracking (main axis)
170
+ mainStartMarginAuto: boolean
171
+ mainEndMarginAuto: boolean
172
+ mainStartMarginValue: number // Resolved or 0 if auto (will be computed later)
173
+ mainEndMarginValue: number // Resolved or 0 if auto (will be computed later)
174
+ // Frozen flag: set when item was clamped to min/max during hypothetical sizing
175
+ frozen: boolean
176
+ }
177
+
178
+ /**
179
+ * A flex line containing children and cross-axis sizing info.
180
+ * Used for flex-wrap to group items that fit on one line.
181
+ */
182
+ interface FlexLine {
183
+ children: ChildLayout[]
184
+ crossSize: number // Maximum cross size of items in this line
185
+ crossStart: number // Computed cross-axis start position
186
+ }
187
+
188
+ /**
189
+ * Break children into flex lines based on available main-axis space.
190
+ *
191
+ * @param children - All children to potentially wrap
192
+ * @param mainAxisSize - Available main-axis space (NaN for unconstrained)
193
+ * @param mainGap - Gap between items on main axis
194
+ * @param wrap - Wrap mode (WRAP_NO_WRAP, WRAP_WRAP, WRAP_WRAP_REVERSE)
195
+ * @returns Array of flex lines
196
+ */
197
+ function breakIntoLines(children: ChildLayout[], mainAxisSize: number, mainGap: number, wrap: number): FlexLine[] {
198
+ // No wrapping or unconstrained - all children on one line
199
+ if (wrap === C.WRAP_NO_WRAP || Number.isNaN(mainAxisSize) || children.length === 0) {
200
+ return [{ children, crossSize: 0, crossStart: 0 }]
201
+ }
202
+
203
+ const lines: FlexLine[] = []
204
+ let currentLine: ChildLayout[] = []
205
+ let lineMainSize = 0
206
+
207
+ for (const child of children) {
208
+ const childMainSize = child.baseSize + child.mainMargin
209
+ const gapIfNotFirst = currentLine.length > 0 ? mainGap : 0
210
+
211
+ // Check if child fits on current line
212
+ if (currentLine.length > 0 && lineMainSize + gapIfNotFirst + childMainSize > mainAxisSize) {
213
+ // Start a new line
214
+ lines.push({ children: currentLine, crossSize: 0, crossStart: 0 })
215
+ currentLine = [child]
216
+ lineMainSize = childMainSize
217
+ } else {
218
+ // Add to current line
219
+ currentLine.push(child)
220
+ lineMainSize += gapIfNotFirst + childMainSize
221
+ }
222
+ }
223
+
224
+ // Don't forget the last line
225
+ if (currentLine.length > 0) {
226
+ lines.push({ children: currentLine, crossSize: 0, crossStart: 0 })
227
+ }
228
+
229
+ // Reverse lines for wrap-reverse
230
+ if (wrap === C.WRAP_WRAP_REVERSE) {
231
+ lines.reverse()
232
+ }
233
+
234
+ return lines
235
+ }
236
+
237
+ /**
238
+ * Distribute free space among flex children using grow or shrink factors.
239
+ * Handles both positive (grow) and negative (shrink) free space.
240
+ *
241
+ * For shrinking, per CSS Flexbox spec, the shrink factor is weighted by the item's
242
+ * base size: scaledShrinkFactor = flexShrink * baseSize
243
+ *
244
+ * @param children - Array of child layout info to distribute space among
245
+ * @param freeSpace - Amount of space to distribute (positive for grow, negative for shrink)
246
+ */
247
+ function distributeFlexSpace(children: ChildLayout[], initialFreeSpace: number): void {
248
+ // CSS Flexbox spec section 9.7: Resolving Flexible Lengths
249
+ // This implements the iterative algorithm where items are frozen when they hit constraints.
250
+ //
251
+ // Key insight: Items start at BASE size, not hypothetical. Free space was calculated from
252
+ // hypothetical sizes. When distributing, items that hit min/max are frozen and we redistribute.
253
+
254
+ const isGrowing = initialFreeSpace > 0
255
+ if (initialFreeSpace === 0) return
256
+
257
+ // Single-child fast path: skip iteration, direct assignment
258
+ if (children.length === 1) {
259
+ const child = children[0]!
260
+ const canFlex = isGrowing ? child.flexGrow > 0 : child.flexShrink > 0
261
+ if (canFlex) {
262
+ const target = child.baseSize + initialFreeSpace
263
+ child.mainSize = Math.max(child.minMain, Math.min(child.maxMain, target))
264
+ }
265
+ return
266
+ }
267
+
268
+ // Calculate container inner size from initial state (before any mutations)
269
+ // freeSpace was computed from BASE sizes, so: container = freeSpace + sum(base)
270
+ let totalBase = 0
271
+ for (const childLayout of children) {
272
+ totalBase += childLayout.baseSize
273
+ }
274
+ const containerInner = initialFreeSpace + totalBase
275
+
276
+ // Initialize: all items start unfrozen
277
+ for (const childLayout of children) {
278
+ childLayout.frozen = false
279
+ }
280
+
281
+ // Track current free space (will be recalculated each iteration)
282
+ let freeSpace = initialFreeSpace
283
+
284
+ // Iterate until all items are frozen or free space is negligible
285
+ let iterations = 0
286
+ const maxIterations = children.length + 1 // Prevent infinite loops
287
+
288
+ while (iterations++ < maxIterations) {
289
+ // Calculate total flex factor for unfrozen items
290
+ let totalFlex = 0
291
+ for (const childLayout of children) {
292
+ if (childLayout.frozen) continue
293
+ if (isGrowing) {
294
+ totalFlex += childLayout.flexGrow
295
+ } else {
296
+ // Shrink weighted by base size per CSS spec
297
+ totalFlex += childLayout.flexShrink * childLayout.baseSize
298
+ }
299
+ }
300
+
301
+ if (totalFlex === 0) break
302
+
303
+ // CSS Flexbox spec: when total flex-grow is less than 1, only distribute that fraction
304
+ let effectiveFreeSpace = freeSpace
305
+ if (isGrowing && totalFlex < 1) {
306
+ effectiveFreeSpace = freeSpace * totalFlex
307
+ }
308
+
309
+ // Calculate target sizes for unfrozen items
310
+ let totalViolation = 0
311
+ for (const childLayout of children) {
312
+ if (childLayout.frozen) continue
313
+
314
+ // Calculate target from base size + proportional free space
315
+ const flexFactor = isGrowing ? childLayout.flexGrow : childLayout.flexShrink * childLayout.baseSize
316
+ const ratio = totalFlex > 0 ? flexFactor / totalFlex : 0
317
+ const target = childLayout.baseSize + effectiveFreeSpace * ratio
318
+
319
+ // Clamp by min/max
320
+ const clamped = Math.max(childLayout.minMain, Math.min(childLayout.maxMain, target))
321
+ const violation = clamped - target
322
+ totalViolation += violation
323
+
324
+ // Store clamped target
325
+ childLayout.mainSize = clamped
326
+ }
327
+
328
+ // Freeze items based on violations (CSS spec 9.7 step 9)
329
+ let anyFrozen = false
330
+ if (Math.abs(totalViolation) < EPSILON_FLOAT) {
331
+ // No violations - freeze all remaining items and we're done
332
+ for (const childLayout of children) {
333
+ childLayout.frozen = true
334
+ }
335
+ break
336
+ } else if (totalViolation > 0) {
337
+ // Positive total violation: freeze items with positive violations (clamped UP to min)
338
+ for (const childLayout of children) {
339
+ if (childLayout.frozen) continue
340
+ const target =
341
+ childLayout.baseSize +
342
+ ((isGrowing ? childLayout.flexGrow : childLayout.flexShrink * childLayout.baseSize) / totalFlex) *
343
+ effectiveFreeSpace
344
+ if (childLayout.mainSize > target + EPSILON_FLOAT) {
345
+ childLayout.frozen = true
346
+ anyFrozen = true
347
+ }
348
+ }
349
+ } else {
350
+ // Negative total violation: freeze items with negative violations (clamped DOWN to max)
351
+ for (const childLayout of children) {
352
+ if (childLayout.frozen) continue
353
+ const flexFactor = isGrowing ? childLayout.flexGrow : childLayout.flexShrink * childLayout.baseSize
354
+ const target = childLayout.baseSize + (flexFactor / totalFlex) * effectiveFreeSpace
355
+ if (childLayout.mainSize < target - EPSILON_FLOAT) {
356
+ childLayout.frozen = true
357
+ anyFrozen = true
358
+ }
359
+ }
360
+ }
361
+
362
+ if (!anyFrozen) break
363
+
364
+ // Recalculate free space for next iteration
365
+ // After freezing, available = container - frozen sizes
366
+ // Free space = available - sum of unfrozen BASE sizes
367
+ let frozenSpace = 0
368
+ let unfrozenBase = 0
369
+ for (const childLayout of children) {
370
+ if (childLayout.frozen) {
371
+ frozenSpace += childLayout.mainSize
372
+ } else {
373
+ unfrozenBase += childLayout.baseSize
374
+ }
375
+ }
376
+ // New free space = container - frozen - unfrozen base sizes
377
+ freeSpace = containerInner - frozenSpace - unfrozenBase
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Compute layout for a node tree.
383
+ *
384
+ * @param root - Root node of the tree
385
+ * @param availableWidth - Available width for layout
386
+ * @param availableHeight - Available height for layout
387
+ * @param direction - Text direction (LTR or RTL), affects horizontal edge resolution
388
+ */
389
+ export function computeLayout(
390
+ root: Node,
391
+ availableWidth: number,
392
+ availableHeight: number,
393
+ direction: number = C.DIRECTION_LTR,
394
+ ): void {
395
+ // Pass absolute position (0,0) for root node - used for Yoga-compatible edge rounding
396
+ layoutNode(root, availableWidth, availableHeight, 0, 0, 0, 0, direction)
397
+ }
398
+
399
+ /**
400
+ * Layout a node and its children.
401
+ *
402
+ * @param absX - Absolute X position from document root (for Yoga-compatible edge rounding)
403
+ * @param absY - Absolute Y position from document root (for Yoga-compatible edge rounding)
404
+ * @param direction - Text direction (LTR or RTL), affects horizontal edge resolution
405
+ */
406
+ function layoutNode(
407
+ node: Node,
408
+ availableWidth: number,
409
+ availableHeight: number,
410
+ offsetX: number,
411
+ offsetY: number,
412
+ absX: number,
413
+ absY: number,
414
+ direction: number = C.DIRECTION_LTR,
415
+ ): void {
416
+ log.debug?.(
417
+ "layoutNode called: availW=%d, availH=%d, offsetX=%d, offsetY=%d, absX=%d, absY=%d, children=%d",
418
+ availableWidth,
419
+ availableHeight,
420
+ offsetX,
421
+ offsetY,
422
+ absX,
423
+ absY,
424
+ node.children.length,
425
+ )
426
+ const style = node.style
427
+ const layout = node.layout
428
+
429
+ // Handle display: none
430
+ if (style.display === C.DISPLAY_NONE) {
431
+ layout.left = 0
432
+ layout.top = 0
433
+ layout.width = 0
434
+ layout.height = 0
435
+ return
436
+ }
437
+
438
+ // Calculate spacing
439
+ // CSS spec: percentage margins AND padding resolve against containing block's WIDTH only
440
+ // Use resolveEdgeValue to respect logical EDGE_START/END
441
+ const marginLeft = resolveEdgeValue(style.margin, 0, style.flexDirection, availableWidth, direction)
442
+ const marginTop = resolveEdgeValue(style.margin, 1, style.flexDirection, availableWidth, direction)
443
+ const marginRight = resolveEdgeValue(style.margin, 2, style.flexDirection, availableWidth, direction)
444
+ const marginBottom = resolveEdgeValue(style.margin, 3, style.flexDirection, availableWidth, direction)
445
+
446
+ const paddingLeft = resolveEdgeValue(style.padding, 0, style.flexDirection, availableWidth, direction)
447
+ const paddingTop = resolveEdgeValue(style.padding, 1, style.flexDirection, availableWidth, direction)
448
+ const paddingRight = resolveEdgeValue(style.padding, 2, style.flexDirection, availableWidth, direction)
449
+ const paddingBottom = resolveEdgeValue(style.padding, 3, style.flexDirection, availableWidth, direction)
450
+
451
+ const borderLeft = resolveEdgeBorderValue(style.border, 0, style.flexDirection, direction)
452
+ const borderTop = resolveEdgeBorderValue(style.border, 1, style.flexDirection, direction)
453
+ const borderRight = resolveEdgeBorderValue(style.border, 2, style.flexDirection, direction)
454
+ const borderBottom = resolveEdgeBorderValue(style.border, 3, style.flexDirection, direction)
455
+
456
+ // Calculate node dimensions
457
+ // When available dimension is NaN (unconstrained), auto-sized nodes use NaN
458
+ // and will be sized by shrink-wrap logic based on children
459
+ let nodeWidth: number
460
+ if (style.width.unit === C.UNIT_POINT) {
461
+ nodeWidth = style.width.value
462
+ } else if (style.width.unit === C.UNIT_PERCENT) {
463
+ // Percentage against NaN (auto-sized parent) resolves to 0 via resolveValue
464
+ nodeWidth = resolveValue(style.width, availableWidth)
465
+ } else if (Number.isNaN(availableWidth)) {
466
+ // Unconstrained: use NaN to signal shrink-wrap (will be computed from children)
467
+ nodeWidth = NaN
468
+ } else {
469
+ nodeWidth = availableWidth - marginLeft - marginRight
470
+ }
471
+ // Apply min/max constraints (works even with NaN available for point-based constraints)
472
+ nodeWidth = applyMinMax(nodeWidth, style.minWidth, style.maxWidth, availableWidth)
473
+
474
+ let nodeHeight: number
475
+ if (style.height.unit === C.UNIT_POINT) {
476
+ nodeHeight = style.height.value
477
+ } else if (style.height.unit === C.UNIT_PERCENT) {
478
+ // Percentage against NaN (auto-sized parent) resolves to 0 via resolveValue
479
+ nodeHeight = resolveValue(style.height, availableHeight)
480
+ } else if (Number.isNaN(availableHeight)) {
481
+ // Unconstrained: use NaN to signal shrink-wrap (will be computed from children)
482
+ nodeHeight = NaN
483
+ } else {
484
+ nodeHeight = availableHeight - marginTop - marginBottom
485
+ }
486
+
487
+ // Apply aspect ratio constraint
488
+ // If aspectRatio is set and one dimension is auto (NaN), derive it from the other
489
+ const aspectRatio = style.aspectRatio
490
+ if (!Number.isNaN(aspectRatio) && aspectRatio > 0) {
491
+ const widthIsAuto = Number.isNaN(nodeWidth) || style.width.unit === C.UNIT_AUTO
492
+ const heightIsAuto = Number.isNaN(nodeHeight) || style.height.unit === C.UNIT_AUTO
493
+
494
+ if (widthIsAuto && !heightIsAuto && !Number.isNaN(nodeHeight)) {
495
+ // Height is defined, width is auto: width = height * aspectRatio
496
+ nodeWidth = nodeHeight * aspectRatio
497
+ } else if (heightIsAuto && !widthIsAuto && !Number.isNaN(nodeWidth)) {
498
+ // Width is defined, height is auto: height = width / aspectRatio
499
+ nodeHeight = nodeWidth / aspectRatio
500
+ }
501
+ // If both are defined or both are auto, aspectRatio doesn't apply at this stage
502
+ }
503
+
504
+ // Apply min/max constraints (works even with NaN available for point-based constraints)
505
+ nodeHeight = applyMinMax(nodeHeight, style.minHeight, style.maxHeight, availableHeight)
506
+
507
+ // Content area (inside border and padding)
508
+ // When node dimensions are NaN (unconstrained), content dimensions are also NaN
509
+ const innerLeft = borderLeft + paddingLeft
510
+ const innerTop = borderTop + paddingTop
511
+ const innerRight = borderRight + paddingRight
512
+ const innerBottom = borderBottom + paddingBottom
513
+
514
+ // Enforce box model constraint: minimum size = padding + border
515
+ const minInnerWidth = innerLeft + innerRight
516
+ const minInnerHeight = innerTop + innerBottom
517
+ if (!Number.isNaN(nodeWidth) && nodeWidth < minInnerWidth) {
518
+ nodeWidth = minInnerWidth
519
+ }
520
+ if (!Number.isNaN(nodeHeight) && nodeHeight < minInnerHeight) {
521
+ nodeHeight = minInnerHeight
522
+ }
523
+
524
+ const contentWidth = Number.isNaN(nodeWidth) ? NaN : Math.max(0, nodeWidth - innerLeft - innerRight)
525
+ const contentHeight = Number.isNaN(nodeHeight) ? NaN : Math.max(0, nodeHeight - innerTop - innerBottom)
526
+
527
+ // Compute position offsets early (needed for children's absolute position calculation)
528
+ // This ensures children's absolute positions include parent's position offset
529
+ let parentPosOffsetX = 0
530
+ let parentPosOffsetY = 0
531
+ if (style.positionType === C.POSITION_TYPE_STATIC || style.positionType === C.POSITION_TYPE_RELATIVE) {
532
+ const leftPos = style.position[0]
533
+ const topPos = style.position[1]
534
+ const rightPos = style.position[2]
535
+ const bottomPos = style.position[3]
536
+
537
+ if (leftPos.unit !== C.UNIT_UNDEFINED) {
538
+ parentPosOffsetX = resolveValue(leftPos, availableWidth)
539
+ } else if (rightPos.unit !== C.UNIT_UNDEFINED) {
540
+ parentPosOffsetX = -resolveValue(rightPos, availableWidth)
541
+ }
542
+
543
+ if (topPos.unit !== C.UNIT_UNDEFINED) {
544
+ parentPosOffsetY = resolveValue(topPos, availableHeight)
545
+ } else if (bottomPos.unit !== C.UNIT_UNDEFINED) {
546
+ parentPosOffsetY = -resolveValue(bottomPos, availableHeight)
547
+ }
548
+ }
549
+
550
+ // Handle measure function (text nodes)
551
+ if (node.hasMeasureFunc() && node.children.length === 0) {
552
+ const measureFunc = node.measureFunc!
553
+ // For unconstrained dimensions (NaN), treat as auto-sizing
554
+ const widthIsAuto =
555
+ style.width.unit === C.UNIT_AUTO || style.width.unit === C.UNIT_UNDEFINED || Number.isNaN(nodeWidth)
556
+ const heightIsAuto =
557
+ style.height.unit === C.UNIT_AUTO || style.height.unit === C.UNIT_UNDEFINED || Number.isNaN(nodeHeight)
558
+ const widthMode = widthIsAuto ? C.MEASURE_MODE_AT_MOST : C.MEASURE_MODE_EXACTLY
559
+ const heightMode = heightIsAuto ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_EXACTLY
560
+
561
+ // For unconstrained width, use a large value; measureFunc should return intrinsic size
562
+ const measureWidth = Number.isNaN(contentWidth) ? Infinity : contentWidth
563
+ const measureHeight = Number.isNaN(contentHeight) ? Infinity : contentHeight
564
+
565
+ const measured = measureFunc(measureWidth, widthMode, measureHeight, heightMode)
566
+
567
+ if (widthIsAuto) {
568
+ nodeWidth = measured.width + innerLeft + innerRight
569
+ }
570
+ if (heightIsAuto) {
571
+ nodeHeight = measured.height + innerTop + innerBottom
572
+ }
573
+
574
+ layout.width = Math.round(nodeWidth)
575
+ layout.height = Math.round(nodeHeight)
576
+ layout.left = Math.round(offsetX + marginLeft)
577
+ layout.top = Math.round(offsetY + marginTop)
578
+ return
579
+ }
580
+
581
+ // Handle leaf nodes without measureFunc - when unconstrained, use padding+border as intrinsic size
582
+ if (node.children.length === 0) {
583
+ // For leaf nodes without measureFunc, intrinsic size is just padding+border
584
+ if (Number.isNaN(nodeWidth)) {
585
+ nodeWidth = innerLeft + innerRight
586
+ }
587
+ if (Number.isNaN(nodeHeight)) {
588
+ nodeHeight = innerTop + innerBottom
589
+ }
590
+ layout.width = Math.round(nodeWidth)
591
+ layout.height = Math.round(nodeHeight)
592
+ layout.left = Math.round(offsetX + marginLeft)
593
+ layout.top = Math.round(offsetY + marginTop)
594
+ return
595
+ }
596
+
597
+ // Separate relative and absolute children
598
+ // Filter out display:none children - they don't participate in layout at all
599
+ const relativeChildren = node.children.filter(
600
+ (c) => c.style.positionType !== C.POSITION_TYPE_ABSOLUTE && c.style.display !== C.DISPLAY_NONE,
601
+ )
602
+ const absoluteChildren = node.children.filter(
603
+ (c) => c.style.positionType === C.POSITION_TYPE_ABSOLUTE && c.style.display !== C.DISPLAY_NONE,
604
+ )
605
+
606
+ // Flex layout for relative children
607
+ log.debug?.(
608
+ "layoutNode: node.children=%d, relativeChildren=%d, absolute=%d",
609
+ node.children.length,
610
+ relativeChildren.length,
611
+ absoluteChildren.length,
612
+ )
613
+ if (relativeChildren.length > 0) {
614
+ const isRow = isRowDirection(style.flexDirection)
615
+ const isReverse = isReverseDirection(style.flexDirection)
616
+
617
+ const mainAxisSize = isRow ? contentWidth : contentHeight
618
+ const crossAxisSize = isRow ? contentHeight : contentWidth
619
+ const mainGap = isRow ? style.gap[0] : style.gap[1]
620
+
621
+ // Prepare child layout info
622
+
623
+ const children: ChildLayout[] = []
624
+ let totalBaseMain = 0
625
+
626
+ for (const child of relativeChildren) {
627
+ const childStyle = child.style
628
+
629
+ // Check for auto margins on main axis
630
+ // Physical indices depend on axis and reverse direction:
631
+ // - Row: main-start=left(0), main-end=right(2)
632
+ // - Row-reverse: main-start=right(2), main-end=left(0)
633
+ // - Column: main-start=top(1), main-end=bottom(3)
634
+ // - Column-reverse: main-start=bottom(3), main-end=top(1)
635
+ const mainStartIndex = isRow ? (isReverse ? 2 : 0) : isReverse ? 3 : 1
636
+ const mainEndIndex = isRow ? (isReverse ? 0 : 2) : isReverse ? 1 : 3
637
+ const mainStartMarginAuto = isEdgeAuto(childStyle.margin, mainStartIndex, style.flexDirection)
638
+ const mainEndMarginAuto = isEdgeAuto(childStyle.margin, mainEndIndex, style.flexDirection)
639
+
640
+ // Resolve non-auto margins (auto margins resolve to 0 initially)
641
+ // CSS spec: percentage margins resolve against containing block's WIDTH only
642
+ // For row: mainAxisSize is contentWidth; for column: crossAxisSize is contentWidth
643
+ const parentWidth = isRow ? mainAxisSize : crossAxisSize
644
+ const mainStartMarginValue = mainStartMarginAuto
645
+ ? 0
646
+ : resolveEdgeValue(childStyle.margin, mainStartIndex, style.flexDirection, parentWidth, direction)
647
+ const mainEndMarginValue = mainEndMarginAuto
648
+ ? 0
649
+ : resolveEdgeValue(childStyle.margin, mainEndIndex, style.flexDirection, parentWidth, direction)
650
+
651
+ // Total non-auto margin for flex calculations
652
+ const mainMargin = mainStartMarginValue + mainEndMarginValue
653
+
654
+ // Determine base size (flex-basis or explicit size)
655
+ let baseSize = 0
656
+ if (childStyle.flexBasis.unit === C.UNIT_POINT) {
657
+ baseSize = childStyle.flexBasis.value
658
+ } else if (childStyle.flexBasis.unit === C.UNIT_PERCENT) {
659
+ baseSize = mainAxisSize * (childStyle.flexBasis.value / 100)
660
+ } else {
661
+ const sizeVal = isRow ? childStyle.width : childStyle.height
662
+ if (sizeVal.unit === C.UNIT_POINT) {
663
+ baseSize = sizeVal.value
664
+ } else if (sizeVal.unit === C.UNIT_PERCENT) {
665
+ baseSize = mainAxisSize * (sizeVal.value / 100)
666
+ } else if (child.hasMeasureFunc() && childStyle.flexGrow === 0) {
667
+ // For auto-sized children with measureFunc but no flexGrow,
668
+ // pre-measure to get intrinsic size for justify-content calculation
669
+ // CSS spec: percentage margins resolve against containing block's WIDTH only
670
+ // Use resolveEdgeValue to respect logical EDGE_START/END
671
+ const crossMargin = isRow
672
+ ? resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction) +
673
+ resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
674
+ : resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction) +
675
+ resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
676
+ const availCross = crossAxisSize - crossMargin
677
+ const measured = child.measureFunc!(
678
+ mainAxisSize,
679
+ C.MEASURE_MODE_AT_MOST,
680
+ availCross,
681
+ C.MEASURE_MODE_UNDEFINED,
682
+ )
683
+ baseSize = isRow ? measured.width : measured.height
684
+ } else if (child.children.length > 0) {
685
+ // For auto-sized children WITH children but no measureFunc,
686
+ // recursively compute intrinsic size by laying out with unconstrained main axis
687
+ // Use 0,0 for absX/absY since this is just measurement, not final positioning
688
+ layoutNode(child, isRow ? NaN : crossAxisSize, isRow ? crossAxisSize : NaN, 0, 0, 0, 0, direction)
689
+ baseSize = isRow ? child.layout.width : child.layout.height
690
+ } else {
691
+ // For auto-sized LEAF children without measureFunc, use padding + border as minimum
692
+ // This ensures elements with only padding still have proper size
693
+ // CSS spec: percentage padding resolves against containing block's WIDTH only
694
+ // Use resolveEdgeValue to respect logical EDGE_START/END
695
+ // For row: mainAxisSize is contentWidth; for column: crossAxisSize is contentWidth
696
+ const parentWidth = isRow ? mainAxisSize : crossAxisSize
697
+ const childPadding = isRow
698
+ ? resolveEdgeValue(childStyle.padding, 0, childStyle.flexDirection, parentWidth, direction) +
699
+ resolveEdgeValue(childStyle.padding, 2, childStyle.flexDirection, parentWidth, direction)
700
+ : resolveEdgeValue(childStyle.padding, 1, childStyle.flexDirection, parentWidth, direction) +
701
+ resolveEdgeValue(childStyle.padding, 3, childStyle.flexDirection, parentWidth, direction)
702
+ const childBorder = isRow
703
+ ? resolveEdgeBorderValue(childStyle.border, 0, childStyle.flexDirection, direction) +
704
+ resolveEdgeBorderValue(childStyle.border, 2, childStyle.flexDirection, direction)
705
+ : resolveEdgeBorderValue(childStyle.border, 1, childStyle.flexDirection, direction) +
706
+ resolveEdgeBorderValue(childStyle.border, 3, childStyle.flexDirection, direction)
707
+ baseSize = childPadding + childBorder
708
+ }
709
+ }
710
+
711
+ // Min/max on main axis
712
+ const minVal = isRow ? childStyle.minWidth : childStyle.minHeight
713
+ const maxVal = isRow ? childStyle.maxWidth : childStyle.maxHeight
714
+ const minMain = minVal.unit !== C.UNIT_UNDEFINED ? resolveValue(minVal, mainAxisSize) : 0
715
+ const maxMain = maxVal.unit !== C.UNIT_UNDEFINED ? resolveValue(maxVal, mainAxisSize) : Infinity
716
+
717
+ // Clamp base size to get hypothetical size (CSS Flexbox spec)
718
+ const hypotheticalSize = Math.max(minMain, Math.min(maxMain, baseSize))
719
+
720
+ children.push({
721
+ node: child,
722
+ mainSize: baseSize, // Start from base size - distribution happens from here
723
+ baseSize,
724
+ mainMargin,
725
+ flexGrow: childStyle.flexGrow,
726
+ flexShrink: childStyle.flexShrink,
727
+ minMain,
728
+ maxMain,
729
+ mainStartMarginAuto,
730
+ mainEndMarginAuto,
731
+ mainStartMarginValue,
732
+ mainEndMarginValue,
733
+ frozen: false, // Will be set during distribution
734
+ })
735
+
736
+ // Free space calculation uses BASE sizes (per Yoga/CSS spec algorithm)
737
+ // The freeze loop handles min/max clamping iteratively
738
+ totalBaseMain += baseSize + mainMargin
739
+ }
740
+
741
+ // Break children into flex lines for wrap support
742
+ const lines = breakIntoLines(children, mainAxisSize, mainGap, style.flexWrap)
743
+ const crossGap = isRow ? style.gap[1] : style.gap[0]
744
+
745
+ // Process each line: distribute flex space
746
+ for (const line of lines) {
747
+ const lineChildren = line.children
748
+ if (lineChildren.length === 0) continue
749
+
750
+ // Calculate total base main and gaps for this line
751
+ const lineTotalBaseMain = lineChildren.reduce((sum, c) => sum + c.baseSize + c.mainMargin, 0)
752
+ const lineTotalGaps = lineChildren.length > 1 ? mainGap * (lineChildren.length - 1) : 0
753
+
754
+ // Distribute free space using grow or shrink factors
755
+ let effectiveMainSize = mainAxisSize
756
+ if (Number.isNaN(mainAxisSize)) {
757
+ // Shrink-wrap mode - check if max constraint applies
758
+ const maxMainVal = isRow ? style.maxWidth : style.maxHeight
759
+ if (maxMainVal.unit !== C.UNIT_UNDEFINED) {
760
+ const maxMain = resolveValue(maxMainVal, isRow ? availableWidth : availableHeight)
761
+ if (!Number.isNaN(maxMain) && lineTotalBaseMain + lineTotalGaps > maxMain) {
762
+ const innerMain = isRow ? innerLeft + innerRight : innerTop + innerBottom
763
+ effectiveMainSize = maxMain - innerMain
764
+ }
765
+ }
766
+ }
767
+
768
+ if (!Number.isNaN(effectiveMainSize)) {
769
+ const adjustedFreeSpace = effectiveMainSize - lineTotalBaseMain - lineTotalGaps
770
+ distributeFlexSpace(lineChildren, adjustedFreeSpace)
771
+ }
772
+
773
+ // Apply min/max constraints to final sizes
774
+ for (const childLayout of lineChildren) {
775
+ childLayout.mainSize = Math.max(childLayout.minMain, Math.min(childLayout.maxMain, childLayout.mainSize))
776
+ }
777
+ }
778
+
779
+ // Calculate final used space and justify-content
780
+ // For single-line, use all children; for multi-line, this applies per-line during positioning
781
+ const totalGaps = children.length > 1 ? mainGap * (children.length - 1) : 0
782
+ const usedMain = children.reduce((sum, c) => sum + c.mainSize + c.mainMargin, 0) + totalGaps
783
+ // For auto-sized containers (NaN mainAxisSize), there's no remaining space to justify
784
+ // Use NaN check instead of style check - handles minWidth/minHeight constraints properly
785
+ const remainingSpace = Number.isNaN(mainAxisSize) ? 0 : mainAxisSize - usedMain
786
+
787
+ // Handle auto margins on main axis
788
+ // Auto margins absorb free space BEFORE justify-content
789
+ const totalAutoMargins = children.reduce(
790
+ (sum, c) => sum + (c.mainStartMarginAuto ? 1 : 0) + (c.mainEndMarginAuto ? 1 : 0),
791
+ 0,
792
+ )
793
+ let hasAutoMargins = totalAutoMargins > 0
794
+
795
+ // Auto margins absorb ALL remaining space (including negative for overflow positioning)
796
+ if (hasAutoMargins) {
797
+ const autoMarginValue = remainingSpace / totalAutoMargins
798
+ for (const childLayout of children) {
799
+ if (childLayout.mainStartMarginAuto) {
800
+ childLayout.mainStartMarginValue = autoMarginValue
801
+ }
802
+ if (childLayout.mainEndMarginAuto) {
803
+ childLayout.mainEndMarginValue = autoMarginValue
804
+ }
805
+ }
806
+ }
807
+ // When space is negative or zero, auto margins stay at 0
808
+
809
+ let startOffset = 0
810
+ let itemSpacing = mainGap
811
+
812
+ // justify-content is ignored when any auto margins exist
813
+ if (!hasAutoMargins) {
814
+ switch (style.justifyContent) {
815
+ case C.JUSTIFY_FLEX_END:
816
+ startOffset = remainingSpace
817
+ break
818
+ case C.JUSTIFY_CENTER:
819
+ startOffset = remainingSpace / 2
820
+ break
821
+ case C.JUSTIFY_SPACE_BETWEEN:
822
+ // Only apply space-between when remaining space is positive
823
+ // With overflow (negative), fall back to flex-start behavior
824
+ if (children.length > 1 && remainingSpace > 0) {
825
+ itemSpacing = mainGap + remainingSpace / (children.length - 1)
826
+ }
827
+ break
828
+ case C.JUSTIFY_SPACE_AROUND:
829
+ if (children.length > 0) {
830
+ const extraSpace = remainingSpace / children.length
831
+ startOffset = extraSpace / 2
832
+ itemSpacing = mainGap + extraSpace
833
+ }
834
+ break
835
+ case C.JUSTIFY_SPACE_EVENLY:
836
+ if (children.length > 0) {
837
+ const extraSpace = remainingSpace / (children.length + 1)
838
+ startOffset = extraSpace
839
+ itemSpacing = mainGap + extraSpace
840
+ }
841
+ break
842
+ }
843
+ }
844
+
845
+ // NOTE: We do NOT round sizes here. Instead, we use edge-based rounding below.
846
+ // This ensures adjacent elements share exact boundaries without gaps.
847
+ // The key insight: round(pos) gives the edge position, width = round(end) - round(start)
848
+
849
+ // Compute baseline alignment info if needed
850
+ // For ALIGN_BASELINE in row direction, we need to know the max baseline first
851
+ let maxBaseline = 0
852
+ const childBaselines: number[] = []
853
+ const hasBaselineAlignment =
854
+ style.alignItems === C.ALIGN_BASELINE || relativeChildren.some((c) => c.style.alignSelf === C.ALIGN_BASELINE)
855
+
856
+ if (hasBaselineAlignment && isRow) {
857
+ // First pass: compute each child's baseline and find the maximum
858
+ for (let i = 0; i < children.length; i++) {
859
+ const childLayout = children[i]!
860
+ const child = childLayout.node
861
+ const childStyle = child.style
862
+
863
+ // Get cross-axis (top/bottom) margins for this child
864
+ // Use resolveEdgeValue to respect logical EDGE_START/END
865
+ const topMargin = resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
866
+
867
+ // Compute child's dimensions - need to do a mini-layout or use the cached size
868
+ let childWidth: number
869
+ let childHeight: number
870
+ const widthDim = childStyle.width
871
+ const heightDim = childStyle.height
872
+
873
+ if (widthDim.unit === C.UNIT_POINT) {
874
+ childWidth = widthDim.value
875
+ } else if (widthDim.unit === C.UNIT_PERCENT && !Number.isNaN(mainAxisSize)) {
876
+ childWidth = mainAxisSize * (widthDim.value / 100)
877
+ } else {
878
+ childWidth = childLayout.mainSize
879
+ }
880
+
881
+ if (heightDim.unit === C.UNIT_POINT) {
882
+ childHeight = heightDim.value
883
+ } else if (heightDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
884
+ childHeight = crossAxisSize * (heightDim.value / 100)
885
+ } else {
886
+ // Auto height - need to layout to get intrinsic size
887
+ // For now, do a preliminary layout (measurement, not final positioning)
888
+ layoutNode(child, childLayout.mainSize, NaN, 0, 0, 0, 0, direction)
889
+ childWidth = child.layout.width
890
+ childHeight = child.layout.height
891
+ }
892
+
893
+ // Compute baseline: use baselineFunc if available, otherwise use bottom of content box
894
+ let baseline: number
895
+ if (child.baselineFunc !== null) {
896
+ // Custom baseline function provided (e.g., for text nodes)
897
+ baseline = topMargin + child.baselineFunc(childWidth, childHeight)
898
+ } else {
899
+ // Fallback: bottom of content box (default for non-text elements)
900
+ // Note: We don't recursively propagate first-child baselines to avoid O(n^depth) cost
901
+ // This is a simplification from CSS spec but acceptable for TUI use cases
902
+ baseline = topMargin + childHeight
903
+ }
904
+ childBaselines.push(baseline)
905
+ maxBaseline = Math.max(maxBaseline, baseline)
906
+ }
907
+ }
908
+
909
+ // Compute line cross-axis sizes and offsets for flex-wrap
910
+ // Each child needs to know its line's cross offset
911
+ const childLineIndex = new Map<ChildLayout, number>()
912
+ const lineCrossOffsets: number[] = []
913
+ let cumulativeCrossOffset = 0
914
+
915
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
916
+ const line = lines[lineIdx]!
917
+ lineCrossOffsets.push(cumulativeCrossOffset)
918
+
919
+ // Calculate max cross size for this line
920
+ let maxLineCross = 0
921
+ for (const childLayout of line.children) {
922
+ childLineIndex.set(childLayout, lineIdx)
923
+ // Estimate child cross size (will be computed more precisely during layout)
924
+ const childStyle = childLayout.node.style
925
+ const crossDim = isRow ? childStyle.height : childStyle.width
926
+ const crossMarginStart = isRow
927
+ ? resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
928
+ : resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction)
929
+ const crossMarginEnd = isRow
930
+ ? resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
931
+ : resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
932
+
933
+ let childCross = 0
934
+ if (crossDim.unit === C.UNIT_POINT) {
935
+ childCross = crossDim.value
936
+ } else if (crossDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
937
+ childCross = crossAxisSize * (crossDim.value / 100)
938
+ } else {
939
+ // Auto - use a default or measure. For now, use 0 and let stretch handle it.
940
+ childCross = 0
941
+ }
942
+ maxLineCross = Math.max(maxLineCross, childCross + crossMarginStart + crossMarginEnd)
943
+ }
944
+ line.crossSize = maxLineCross > 0 ? maxLineCross : crossAxisSize / lines.length
945
+ cumulativeCrossOffset += line.crossSize + crossGap
946
+ }
947
+
948
+ // Apply alignContent to distribute lines in the cross axis
949
+ // This affects how multiple flex lines are positioned within the container
950
+ const isWrapReverse = style.flexWrap === C.WRAP_WRAP_REVERSE
951
+ const numLines = lines.length
952
+ if (!Number.isNaN(crossAxisSize) && numLines > 0) {
953
+ const totalLineCrossSize = cumulativeCrossOffset - crossGap // Remove trailing gap
954
+ const freeSpace = crossAxisSize - totalLineCrossSize
955
+ const alignContent = style.alignContent
956
+
957
+ // Reset offsets based on alignContent
958
+ if (freeSpace > 0 || alignContent === C.ALIGN_STRETCH) {
959
+ switch (alignContent) {
960
+ case C.ALIGN_FLEX_END:
961
+ // Lines packed at end
962
+ for (let i = 0; i < numLines; i++) {
963
+ lineCrossOffsets[i]! += freeSpace
964
+ }
965
+ break
966
+
967
+ case C.ALIGN_CENTER:
968
+ // Lines centered
969
+ const centerOffset = freeSpace / 2
970
+ for (let i = 0; i < numLines; i++) {
971
+ lineCrossOffsets[i]! += centerOffset
972
+ }
973
+ break
974
+
975
+ case C.ALIGN_SPACE_BETWEEN:
976
+ // First line at start, last at end, evenly distributed
977
+ if (numLines > 1) {
978
+ const gap = freeSpace / (numLines - 1)
979
+ for (let i = 1; i < numLines; i++) {
980
+ lineCrossOffsets[i]! += gap * i
981
+ }
982
+ }
983
+ break
984
+
985
+ case C.ALIGN_SPACE_AROUND:
986
+ // Even spacing with half-space at edges
987
+ const halfGap = freeSpace / (numLines * 2)
988
+ for (let i = 0; i < numLines; i++) {
989
+ lineCrossOffsets[i]! += halfGap + halfGap * 2 * i
990
+ }
991
+ break
992
+
993
+ case C.ALIGN_STRETCH:
994
+ // Distribute extra space evenly among lines
995
+ if (freeSpace > 0 && numLines > 0) {
996
+ const extraPerLine = freeSpace / numLines
997
+ for (let i = 0; i < numLines; i++) {
998
+ lines[i]!.crossSize += extraPerLine
999
+ // Recalculate offset for subsequent lines
1000
+ if (i > 0) {
1001
+ lineCrossOffsets[i] = lineCrossOffsets[i - 1]! + lines[i - 1]!.crossSize + crossGap
1002
+ }
1003
+ }
1004
+ }
1005
+ break
1006
+
1007
+ // ALIGN_FLEX_START is the default - lines already at start
1008
+ }
1009
+ }
1010
+
1011
+ // For wrap-reverse, lines should be positioned from the end of the cross axis
1012
+ // The lines are already in reversed order from breakIntoLines().
1013
+ // We just need to shift them so they align to the end instead of the start.
1014
+ if (isWrapReverse) {
1015
+ let totalLineCrossSize = 0
1016
+ for (let i = 0; i < numLines; i++) {
1017
+ totalLineCrossSize += lines[i]!.crossSize
1018
+ }
1019
+ totalLineCrossSize += crossGap * (numLines - 1)
1020
+ const crossStartOffset = crossAxisSize - totalLineCrossSize
1021
+ for (let i = 0; i < numLines; i++) {
1022
+ lineCrossOffsets[i]! += crossStartOffset
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ // Position and layout children
1028
+ // For reverse directions, start from the END of the container
1029
+ // For RTL row layouts, treat as reversed (children flow right-to-left)
1030
+ // RTL + reverse cancels out (XOR behavior)
1031
+ const isRTL = direction === C.DIRECTION_RTL
1032
+ const effectiveReverse = isRow ? isRTL !== isReverse : isReverse
1033
+ // Use fractional mainPos for edge-based rounding
1034
+ let mainPos = effectiveReverse ? mainAxisSize - startOffset : startOffset
1035
+ let currentLineIdx = -1
1036
+
1037
+ log.debug?.(
1038
+ "positioning children: isRow=%s, startOffset=%d, relativeChildren=%d, isReverse=%s, lines=%d",
1039
+ isRow,
1040
+ startOffset,
1041
+ relativeChildren.length,
1042
+ isReverse,
1043
+ lines.length,
1044
+ )
1045
+
1046
+ for (let i = 0; i < children.length; i++) {
1047
+ const childLayout = children[i]!
1048
+ const child = childLayout.node
1049
+ const childStyle = child.style
1050
+
1051
+ // Check if we've moved to a new line (for flex-wrap)
1052
+ const childLineIdx = childLineIndex.get(childLayout) ?? 0
1053
+ if (childLineIdx !== currentLineIdx) {
1054
+ currentLineIdx = childLineIdx
1055
+ // Reset mainPos for new line
1056
+ mainPos = effectiveReverse ? mainAxisSize - startOffset : startOffset
1057
+ }
1058
+
1059
+ // Get cross-axis offset for this child's line
1060
+ const lineCrossOffset = lineCrossOffsets[childLineIdx] ?? 0
1061
+
1062
+ // For main-axis margins, use computed auto margin values
1063
+ // For cross-axis margins, resolve normally (auto margins on cross axis handled separately)
1064
+ let childMarginLeft: number
1065
+ let childMarginTop: number
1066
+ let childMarginRight: number
1067
+ let childMarginBottom: number
1068
+
1069
+ // CSS spec: percentage margins resolve against containing block's WIDTH only
1070
+ // Use resolveEdgeValue to respect logical EDGE_START/END
1071
+ if (isRow) {
1072
+ // Row: main axis is horizontal
1073
+ // In row-reverse, mainStart=right(2), mainEnd=left(0)
1074
+ childMarginLeft =
1075
+ childLayout.mainStartMarginAuto && !isReverse
1076
+ ? childLayout.mainStartMarginValue
1077
+ : childLayout.mainEndMarginAuto && isReverse
1078
+ ? childLayout.mainEndMarginValue
1079
+ : resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction)
1080
+ childMarginRight =
1081
+ childLayout.mainEndMarginAuto && !isReverse
1082
+ ? childLayout.mainEndMarginValue
1083
+ : childLayout.mainStartMarginAuto && isReverse
1084
+ ? childLayout.mainStartMarginValue
1085
+ : resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
1086
+ childMarginTop = resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
1087
+ childMarginBottom = resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
1088
+ } else {
1089
+ // Column: main axis is vertical
1090
+ // In column-reverse, mainStart=bottom(3), mainEnd=top(1)
1091
+ childMarginTop =
1092
+ childLayout.mainStartMarginAuto && !isReverse
1093
+ ? childLayout.mainStartMarginValue
1094
+ : childLayout.mainEndMarginAuto && isReverse
1095
+ ? childLayout.mainEndMarginValue
1096
+ : resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
1097
+ childMarginBottom =
1098
+ childLayout.mainEndMarginAuto && !isReverse
1099
+ ? childLayout.mainEndMarginValue
1100
+ : childLayout.mainStartMarginAuto && isReverse
1101
+ ? childLayout.mainStartMarginValue
1102
+ : resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
1103
+ childMarginLeft = resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction)
1104
+ childMarginRight = resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
1105
+ }
1106
+
1107
+ // Main axis size comes from flex algorithm (already rounded)
1108
+ const childMainSize = childLayout.mainSize
1109
+
1110
+ // Cross axis: determine alignment mode
1111
+ let alignment = style.alignItems
1112
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1113
+ alignment = childStyle.alignSelf
1114
+ }
1115
+
1116
+ // Cross axis size depends on alignment and child's explicit dimensions
1117
+ // IMPORTANT: Resolve percent against parent's cross axis, not child's available
1118
+ let childCrossSize: number
1119
+ const crossDim = isRow ? childStyle.height : childStyle.width
1120
+ const crossMargin = isRow ? childMarginTop + childMarginBottom : childMarginLeft + childMarginRight
1121
+
1122
+ // Check if parent has definite cross-axis size
1123
+ // Parent can have definite cross from either:
1124
+ // 1. Explicit style (width/height in points or percent)
1125
+ // 2. Definite available space (crossAxisSize is not NaN)
1126
+ const parentCrossDim = isRow ? style.height : style.width
1127
+ const parentHasDefiniteCrossStyle = parentCrossDim.unit === C.UNIT_POINT || parentCrossDim.unit === C.UNIT_PERCENT
1128
+ // crossAxisSize comes from available space - if it's a real number, we have a constraint
1129
+ const parentHasDefiniteCross = parentHasDefiniteCrossStyle || !Number.isNaN(crossAxisSize)
1130
+
1131
+ if (crossDim.unit === C.UNIT_POINT) {
1132
+ // Explicit cross size
1133
+ childCrossSize = crossDim.value
1134
+ } else if (crossDim.unit === C.UNIT_PERCENT) {
1135
+ // Percent of PARENT's cross axis (resolveValue handles NaN → 0)
1136
+ childCrossSize = resolveValue(crossDim, crossAxisSize)
1137
+ } else if (parentHasDefiniteCross && alignment === C.ALIGN_STRETCH) {
1138
+ // Stretch alignment with definite parent cross size - fill the cross axis
1139
+ childCrossSize = crossAxisSize - crossMargin
1140
+ } else {
1141
+ // Non-stretch alignment or no definite cross size - shrink-wrap to content
1142
+ childCrossSize = NaN
1143
+ }
1144
+
1145
+ // Apply cross-axis min/max constraints
1146
+ const crossMinVal = isRow ? childStyle.minHeight : childStyle.minWidth
1147
+ const crossMaxVal = isRow ? childStyle.maxHeight : childStyle.maxWidth
1148
+ const crossMin = crossMinVal.unit !== C.UNIT_UNDEFINED ? resolveValue(crossMinVal, crossAxisSize) : 0
1149
+ const crossMax = crossMaxVal.unit !== C.UNIT_UNDEFINED ? resolveValue(crossMaxVal, crossAxisSize) : Infinity
1150
+
1151
+ // Apply constraints - for NaN (shrink-wrap), use min as floor
1152
+ if (Number.isNaN(childCrossSize)) {
1153
+ // For shrink-wrap, min sets the floor - child will be at least this size
1154
+ if (crossMin > 0) {
1155
+ childCrossSize = crossMin
1156
+ }
1157
+ } else {
1158
+ childCrossSize = Math.max(crossMin, Math.min(crossMax, childCrossSize))
1159
+ }
1160
+
1161
+ // Handle intrinsic sizing for auto-sized children
1162
+ // For auto main size children, use flex-computed size if flexGrow > 0,
1163
+ // otherwise pass remaining available space for shrink-wrap behavior
1164
+ const mainDim = isRow ? childStyle.width : childStyle.height
1165
+ const mainIsAuto = mainDim.unit === C.UNIT_AUTO || mainDim.unit === C.UNIT_UNDEFINED
1166
+ const hasFlexGrow = childLayout.flexGrow > 0
1167
+ // Check if parent has definite main-axis size
1168
+ const parentMainDim = isRow ? style.width : style.height
1169
+ const parentHasDefiniteMain = parentMainDim.unit === C.UNIT_POINT || parentMainDim.unit === C.UNIT_PERCENT
1170
+ // Use flex-computed mainSize for all cases - it includes padding/border as minimum
1171
+ // The flex algorithm already computed the proper size based on content/padding/border
1172
+ let effectiveMainSize: number
1173
+ if (hasFlexGrow) {
1174
+ effectiveMainSize = childMainSize
1175
+ } else if (mainIsAuto) {
1176
+ // Child is auto: use flex-computed size which includes padding/border minimum
1177
+ effectiveMainSize = childMainSize
1178
+ } else {
1179
+ effectiveMainSize = childMainSize
1180
+ }
1181
+
1182
+ let childWidth = isRow ? effectiveMainSize : childCrossSize
1183
+ let childHeight = isRow ? childCrossSize : effectiveMainSize
1184
+
1185
+ // Only use measure function for intrinsic sizing when flexGrow is NOT set
1186
+ // When flexGrow > 0, the flex algorithm determines size, not the content
1187
+ const shouldMeasure = child.hasMeasureFunc() && child.children.length === 0 && !hasFlexGrow
1188
+ if (shouldMeasure) {
1189
+ const widthAuto = childStyle.width.unit === C.UNIT_AUTO || childStyle.width.unit === C.UNIT_UNDEFINED
1190
+ const heightAuto = childStyle.height.unit === C.UNIT_AUTO || childStyle.height.unit === C.UNIT_UNDEFINED
1191
+
1192
+ if (widthAuto || heightAuto) {
1193
+ // Call measure function with available space
1194
+ const widthMode = widthAuto ? C.MEASURE_MODE_AT_MOST : C.MEASURE_MODE_EXACTLY
1195
+ const heightMode = heightAuto ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_EXACTLY
1196
+
1197
+ // For unconstrained dimensions (NaN), use Infinity for measure func
1198
+ const rawAvailW = widthAuto
1199
+ ? isRow
1200
+ ? mainAxisSize - mainPos // Remaining space after previous children
1201
+ : crossAxisSize - crossMargin
1202
+ : childStyle.width.value
1203
+ const rawAvailH = heightAuto
1204
+ ? isRow
1205
+ ? crossAxisSize - crossMargin
1206
+ : mainAxisSize - mainPos // Remaining space for COLUMN
1207
+ : childStyle.height.value
1208
+ const availW = Number.isNaN(rawAvailW) ? Infinity : rawAvailW
1209
+ const availH = Number.isNaN(rawAvailH) ? Infinity : rawAvailH
1210
+
1211
+ const measured = child.measureFunc!(availW, widthMode, availH, heightMode)
1212
+
1213
+ // For measure function nodes without flexGrow, intrinsic size takes precedence
1214
+ if (widthAuto) {
1215
+ childWidth = measured.width
1216
+ }
1217
+ if (heightAuto) {
1218
+ childHeight = measured.height
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ // Child position within content area (fractional for edge-based rounding)
1224
+ // For reverse directions (including RTL for row), position from mainPos - childSize
1225
+ // IMPORTANT: In reverse, swap which margin is applied to which side
1226
+ // EDGE_START (margin[0]/[1]) becomes the trailing margin in reverse layout
1227
+ // EDGE_END (margin[2]/[3]) becomes the leading margin in reverse layout
1228
+ // For flex-wrap, add lineCrossOffset to cross-axis position
1229
+ let childX: number
1230
+ let childY: number
1231
+ if (effectiveReverse) {
1232
+ if (isRow) {
1233
+ // Row-reverse or RTL: items positioned from right
1234
+ // In RTL, EDGE_START is the right edge, so use childMarginRight as trailing margin
1235
+ // In row-reverse LTR, EDGE_END is the right edge, so use childMarginRight too
1236
+ childX = mainPos - childMainSize - childMarginRight
1237
+ childY = lineCrossOffset + childMarginTop
1238
+ } else {
1239
+ // Column-reverse: items positioned from bottom
1240
+ childX = lineCrossOffset + childMarginLeft
1241
+ childY = mainPos - childMainSize - childMarginTop
1242
+ }
1243
+ } else {
1244
+ childX = isRow ? mainPos + childMarginLeft : lineCrossOffset + childMarginLeft
1245
+ childY = isRow ? lineCrossOffset + childMarginTop : mainPos + childMarginTop
1246
+ }
1247
+
1248
+ // Edge-based rounding using ABSOLUTE coordinates (Yoga-compatible)
1249
+ // This ensures adjacent elements share exact boundaries without gaps
1250
+ // Key insight: round absolute edges, derive sizes from differences
1251
+ const fractionalLeft = innerLeft + childX
1252
+ const fractionalTop = innerTop + childY
1253
+
1254
+ // Compute position offsets for RELATIVE/STATIC positioned children
1255
+ // These must be included in the absolute position BEFORE rounding (Yoga-compatible)
1256
+ let posOffsetX = 0
1257
+ let posOffsetY = 0
1258
+ if (childStyle.positionType === C.POSITION_TYPE_RELATIVE || childStyle.positionType === C.POSITION_TYPE_STATIC) {
1259
+ const relLeftPos = childStyle.position[0]
1260
+ const relTopPos = childStyle.position[1]
1261
+ const relRightPos = childStyle.position[2]
1262
+ const relBottomPos = childStyle.position[3]
1263
+
1264
+ // Left offset (takes precedence over right)
1265
+ if (relLeftPos.unit !== C.UNIT_UNDEFINED) {
1266
+ posOffsetX = resolveValue(relLeftPos, contentWidth)
1267
+ } else if (relRightPos.unit !== C.UNIT_UNDEFINED) {
1268
+ posOffsetX = -resolveValue(relRightPos, contentWidth)
1269
+ }
1270
+
1271
+ // Top offset (takes precedence over bottom)
1272
+ if (relTopPos.unit !== C.UNIT_UNDEFINED) {
1273
+ posOffsetY = resolveValue(relTopPos, contentHeight)
1274
+ } else if (relBottomPos.unit !== C.UNIT_UNDEFINED) {
1275
+ posOffsetY = -resolveValue(relBottomPos, contentHeight)
1276
+ }
1277
+ }
1278
+
1279
+ // Compute ABSOLUTE float positions for edge rounding (including position offsets)
1280
+ // absX/absY are the parent's absolute position from document root
1281
+ // Include BOTH parent's position offset and child's position offset
1282
+ const absChildLeft = absX + marginLeft + parentPosOffsetX + fractionalLeft + posOffsetX
1283
+ const absChildTop = absY + marginTop + parentPosOffsetY + fractionalTop + posOffsetY
1284
+
1285
+ // For main axis: round ABSOLUTE edges and derive size
1286
+ // Only use edge-based rounding when childMainSize is valid (positive)
1287
+ let roundedAbsMainStart: number
1288
+ let roundedAbsMainEnd: number
1289
+ let edgeBasedMainSize: number
1290
+ const useEdgeBasedRounding = childMainSize > 0
1291
+
1292
+ // Compute child's box model minimum early (needed for edge-based rounding)
1293
+ // Use resolveEdgeValue to respect logical EDGE_START/END for padding
1294
+ const childPaddingL = resolveEdgeValue(childStyle.padding, 0, childStyle.flexDirection, contentWidth, direction)
1295
+ const childPaddingT = resolveEdgeValue(childStyle.padding, 1, childStyle.flexDirection, contentWidth, direction)
1296
+ const childPaddingR = resolveEdgeValue(childStyle.padding, 2, childStyle.flexDirection, contentWidth, direction)
1297
+ const childPaddingB = resolveEdgeValue(childStyle.padding, 3, childStyle.flexDirection, contentWidth, direction)
1298
+ const childBorderL = resolveEdgeBorderValue(childStyle.border, 0, childStyle.flexDirection, direction)
1299
+ const childBorderT = resolveEdgeBorderValue(childStyle.border, 1, childStyle.flexDirection, direction)
1300
+ const childBorderR = resolveEdgeBorderValue(childStyle.border, 2, childStyle.flexDirection, direction)
1301
+ const childBorderB = resolveEdgeBorderValue(childStyle.border, 3, childStyle.flexDirection, direction)
1302
+ const childMinW = childPaddingL + childPaddingR + childBorderL + childBorderR
1303
+ const childMinH = childPaddingT + childPaddingB + childBorderT + childBorderB
1304
+ const childMinMain = isRow ? childMinW : childMinH
1305
+
1306
+ // Apply box model constraint to childMainSize before edge rounding
1307
+ const constrainedMainSize = Math.max(childMainSize, childMinMain)
1308
+
1309
+ if (useEdgeBasedRounding) {
1310
+ if (isRow) {
1311
+ roundedAbsMainStart = Math.round(absChildLeft)
1312
+ roundedAbsMainEnd = Math.round(absChildLeft + constrainedMainSize)
1313
+ edgeBasedMainSize = roundedAbsMainEnd - roundedAbsMainStart
1314
+ } else {
1315
+ roundedAbsMainStart = Math.round(absChildTop)
1316
+ roundedAbsMainEnd = Math.round(absChildTop + constrainedMainSize)
1317
+ edgeBasedMainSize = roundedAbsMainEnd - roundedAbsMainStart
1318
+ }
1319
+ } else {
1320
+ // For children without valid main size, use simple rounding
1321
+ roundedAbsMainStart = isRow ? Math.round(absChildLeft) : Math.round(absChildTop)
1322
+ edgeBasedMainSize = childMinMain // Use minimum size instead of 0
1323
+ }
1324
+
1325
+ // Calculate child's RELATIVE position (stored in layout)
1326
+ // Yoga behavior: position is rounded locally, size uses absolute edge rounding
1327
+ // This ensures sizes are pixel-perfect at document level while positions remain intuitive
1328
+ const childLeft = Math.round(fractionalLeft + posOffsetX)
1329
+ const childTop = Math.round(fractionalTop + posOffsetY)
1330
+
1331
+ // Check if cross axis is auto-sized (needed for deciding what to pass to layoutNode)
1332
+ const crossDimForLayoutCall = isRow ? childStyle.height : childStyle.width
1333
+ const crossIsAutoForLayoutCall =
1334
+ crossDimForLayoutCall.unit === C.UNIT_AUTO || crossDimForLayoutCall.unit === C.UNIT_UNDEFINED
1335
+ const mainDimForLayoutCall = isRow ? childStyle.width : childStyle.height
1336
+
1337
+ // For auto-sized children (no flexGrow, no measureFunc), pass NaN to let them compute intrinsic size
1338
+ // Otherwise layoutNode would subtract margins from the available size
1339
+ // IMPORTANT: For percent-sized children, pass PARENT's content size so the child resolves its
1340
+ // percent against the correct containing block. This ensures grandchildren also resolve correctly.
1341
+ const mainIsPercent = mainDimForLayoutCall.unit === C.UNIT_PERCENT
1342
+ const crossIsPercent = crossDimForLayoutCall.unit === C.UNIT_PERCENT
1343
+
1344
+ let passWidthToChild: number
1345
+ if (isRow && mainIsAuto && !hasFlexGrow) {
1346
+ passWidthToChild = NaN
1347
+ } else if (!isRow && crossIsAutoForLayoutCall && !parentHasDefiniteCross) {
1348
+ passWidthToChild = NaN
1349
+ } else if (isRow && mainIsPercent) {
1350
+ // Percent width (main axis in row): pass parent's content width
1351
+ passWidthToChild = contentWidth
1352
+ } else if (!isRow && crossIsPercent) {
1353
+ // Percent width (cross axis in column): pass parent's content width
1354
+ passWidthToChild = contentWidth
1355
+ } else {
1356
+ passWidthToChild = childWidth
1357
+ }
1358
+
1359
+ let passHeightToChild: number
1360
+ if (!isRow && mainIsAuto && !hasFlexGrow) {
1361
+ passHeightToChild = NaN
1362
+ } else if (isRow && crossIsAutoForLayoutCall && !parentHasDefiniteCross) {
1363
+ passHeightToChild = NaN
1364
+ } else if (!isRow && mainIsPercent) {
1365
+ // Percent height (main axis in column): pass parent's content height
1366
+ passHeightToChild = contentHeight
1367
+ } else if (isRow && crossIsPercent) {
1368
+ // Percent height (cross axis in row): pass parent's content height
1369
+ passHeightToChild = contentHeight
1370
+ } else {
1371
+ passHeightToChild = childHeight
1372
+ }
1373
+
1374
+ // Recurse to layout any grandchildren
1375
+ // Pass the child's FLOAT absolute position (margin box start, before child's own margin)
1376
+ // absChildLeft/Top include the child's margins, so subtract them to get margin box start
1377
+ const childAbsX = absChildLeft - childMarginLeft
1378
+ const childAbsY = absChildTop - childMarginTop
1379
+ layoutNode(child, passWidthToChild, passHeightToChild, childLeft, childTop, childAbsX, childAbsY, direction)
1380
+
1381
+ // Enforce box model constraint: child can't be smaller than its padding + border
1382
+ // (using childMinW/childMinH computed earlier for edge-based rounding)
1383
+ if (childWidth < childMinW) childWidth = childMinW
1384
+ if (childHeight < childMinH) childHeight = childMinH
1385
+
1386
+ // Set this child's layout - override what layoutNode computed
1387
+ // Override if any of:
1388
+ // - Child has explicit main dimension (not auto)
1389
+ // - Child has flexGrow > 0 (flex distribution applied)
1390
+ // - Child has measureFunc
1391
+ // - Parent did flex distribution (effectiveMainSize not NaN) - covers flex-shrink case
1392
+ const hasMeasure = child.hasMeasureFunc() && child.children.length === 0
1393
+ const parentDidFlexDistribution = !Number.isNaN(effectiveMainSize)
1394
+ if (!mainIsAuto || hasFlexGrow || hasMeasure || parentDidFlexDistribution) {
1395
+ // Use edge-based rounding: size = round(end_edge) - round(start_edge)
1396
+ if (isRow) {
1397
+ child.layout.width = edgeBasedMainSize
1398
+ } else {
1399
+ child.layout.height = edgeBasedMainSize
1400
+ }
1401
+ }
1402
+ // Cross axis: only override for explicit sizing or when we have a real constraint
1403
+ // For auto-sized children, let layoutNode determine the size
1404
+ const crossDimForCheck = isRow ? childStyle.height : childStyle.width
1405
+ const crossIsAuto = crossDimForCheck.unit === C.UNIT_AUTO || crossDimForCheck.unit === C.UNIT_UNDEFINED
1406
+ // Only override if child has explicit sizing OR parent has explicit cross size
1407
+ // When parent has auto cross size, let children shrink-wrap first
1408
+ // Note: parentCrossDim and parentHasDefiniteCross already computed above
1409
+ const parentCrossIsAuto = !parentHasDefiniteCross
1410
+ // Also check if childCrossSize was constrained by min/max - if so, we should override
1411
+ const hasCrossMinMax = crossMinVal.unit !== C.UNIT_UNDEFINED || crossMaxVal.unit !== C.UNIT_UNDEFINED
1412
+ const shouldOverrideCross =
1413
+ !crossIsAuto ||
1414
+ (!parentCrossIsAuto && alignment === C.ALIGN_STRETCH) ||
1415
+ (hasCrossMinMax && !Number.isNaN(childCrossSize))
1416
+ if (shouldOverrideCross) {
1417
+ if (isRow) {
1418
+ child.layout.height = Math.round(childHeight)
1419
+ } else {
1420
+ child.layout.width = Math.round(childWidth)
1421
+ }
1422
+ }
1423
+ // Store RELATIVE position (within parent's content area), not absolute
1424
+ // This matches Yoga's behavior where getComputedLeft/Top return relative positions
1425
+ // Position offsets are already included in childLeft/childTop via edge-based rounding
1426
+ child.layout.left = childLeft
1427
+ child.layout.top = childTop
1428
+
1429
+ // Update childWidth/childHeight to match actual computed layout for mainPos calculation
1430
+ childWidth = child.layout.width
1431
+ childHeight = child.layout.height
1432
+
1433
+ // Apply cross-axis alignment offset
1434
+ const finalCrossSize = isRow ? child.layout.height : child.layout.width
1435
+ let crossOffset = 0
1436
+
1437
+ // Check for auto margins on cross axis - they override alignment
1438
+ const crossStartMargin = isRow ? childStyle.margin[1] : childStyle.margin[0] // top for row, left for column
1439
+ const crossEndMargin = isRow ? childStyle.margin[3] : childStyle.margin[2] // bottom for row, right for column
1440
+ const hasAutoStartMargin = crossStartMargin.unit === C.UNIT_AUTO
1441
+ const hasAutoEndMargin = crossEndMargin.unit === C.UNIT_AUTO
1442
+ const availableCrossSpace = crossAxisSize - finalCrossSize - crossMargin
1443
+
1444
+ if (hasAutoStartMargin && hasAutoEndMargin) {
1445
+ // Both auto: center the item
1446
+ crossOffset = availableCrossSpace / 2
1447
+ } else if (hasAutoStartMargin) {
1448
+ // Auto start margin: push to end
1449
+ crossOffset = availableCrossSpace
1450
+ } else if (hasAutoEndMargin) {
1451
+ // Auto end margin: stay at start (crossOffset = 0)
1452
+ crossOffset = 0
1453
+ } else {
1454
+ // No auto margins: use alignment
1455
+ switch (alignment) {
1456
+ case C.ALIGN_FLEX_END:
1457
+ crossOffset = availableCrossSpace
1458
+ break
1459
+ case C.ALIGN_CENTER:
1460
+ crossOffset = availableCrossSpace / 2
1461
+ break
1462
+ case C.ALIGN_BASELINE:
1463
+ // Baseline alignment only applies to row direction
1464
+ // For column direction, it falls through to flex-start (default)
1465
+ if (isRow && childBaselines.length > 0) {
1466
+ crossOffset = maxBaseline - childBaselines[i]!
1467
+ }
1468
+ break
1469
+ }
1470
+ }
1471
+
1472
+ if (crossOffset > 0) {
1473
+ if (isRow) {
1474
+ child.layout.top += Math.round(crossOffset)
1475
+ } else {
1476
+ child.layout.left += Math.round(crossOffset)
1477
+ }
1478
+ }
1479
+
1480
+ // Advance main position using CONSTRAINED size for proper positioning
1481
+ // Use constrainedMainSize (box model minimum applied) instead of childLayout.mainSize
1482
+ const fractionalMainSize = constrainedMainSize
1483
+ // Use computed margin values (including auto margins)
1484
+ const totalMainMargin = childLayout.mainStartMarginValue + childLayout.mainEndMarginValue
1485
+ log.debug?.(
1486
+ " child %d: mainPos=%d → top=%d (fractionalMainSize=%d, totalMainMargin=%d)",
1487
+ i,
1488
+ mainPos,
1489
+ child.layout.top,
1490
+ fractionalMainSize,
1491
+ totalMainMargin,
1492
+ )
1493
+ if (effectiveReverse) {
1494
+ mainPos -= fractionalMainSize + totalMainMargin
1495
+ if (i < children.length - 1) {
1496
+ mainPos -= itemSpacing
1497
+ }
1498
+ } else {
1499
+ mainPos += fractionalMainSize + totalMainMargin
1500
+ if (i < children.length - 1) {
1501
+ mainPos += itemSpacing
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ // For auto-sized containers (including root), shrink-wrap to content
1507
+ // Compute actual used main space from child layouts (not pre-computed childLayout.mainSize which may be 0)
1508
+ let actualUsedMain = 0
1509
+ for (const childLayout of children) {
1510
+ const childMainSize = isRow ? childLayout.node.layout.width : childLayout.node.layout.height
1511
+ const totalMainMargin = childLayout.mainStartMarginValue + childLayout.mainEndMarginValue
1512
+ actualUsedMain += childMainSize + totalMainMargin
1513
+ }
1514
+ actualUsedMain += totalGaps
1515
+
1516
+ if (isRow && style.width.unit !== C.UNIT_POINT && style.width.unit !== C.UNIT_PERCENT) {
1517
+ // Auto-width row: shrink-wrap to content
1518
+ nodeWidth = actualUsedMain + innerLeft + innerRight
1519
+ }
1520
+ if (!isRow && style.height.unit !== C.UNIT_POINT && style.height.unit !== C.UNIT_PERCENT) {
1521
+ // Auto-height column: shrink-wrap to content
1522
+ nodeHeight = actualUsedMain + innerTop + innerBottom
1523
+ }
1524
+ // For cross axis, find the max child size
1525
+ // CSS spec: percentage margins resolve against containing block's WIDTH only
1526
+ // Use resolveEdgeValue to respect logical EDGE_START/END
1527
+ let maxCrossSize = 0
1528
+ for (const childLayout of children) {
1529
+ const childCross = isRow ? childLayout.node.layout.height : childLayout.node.layout.width
1530
+ const childMargin = isRow
1531
+ ? resolveEdgeValue(childLayout.node.style.margin, 1, style.flexDirection, contentWidth, direction) +
1532
+ resolveEdgeValue(childLayout.node.style.margin, 3, style.flexDirection, contentWidth, direction)
1533
+ : resolveEdgeValue(childLayout.node.style.margin, 0, style.flexDirection, contentWidth, direction) +
1534
+ resolveEdgeValue(childLayout.node.style.margin, 2, style.flexDirection, contentWidth, direction)
1535
+ maxCrossSize = Math.max(maxCrossSize, childCross + childMargin)
1536
+ }
1537
+ // Cross-axis shrink-wrap for auto-sized dimension
1538
+ // Only shrink-wrap if the original available size was truly unconstrained (NaN)
1539
+ // If a definite size was passed to calculateLayout, keep that size
1540
+ if (
1541
+ isRow &&
1542
+ style.height.unit !== C.UNIT_POINT &&
1543
+ style.height.unit !== C.UNIT_PERCENT &&
1544
+ Number.isNaN(availableHeight)
1545
+ ) {
1546
+ // Auto-height row with unconstrained height: shrink-wrap to max child height
1547
+ nodeHeight = maxCrossSize + innerTop + innerBottom
1548
+ }
1549
+ if (
1550
+ !isRow &&
1551
+ style.width.unit !== C.UNIT_POINT &&
1552
+ style.width.unit !== C.UNIT_PERCENT &&
1553
+ Number.isNaN(availableWidth)
1554
+ ) {
1555
+ // Auto-width column with unconstrained width: shrink-wrap to max child width
1556
+ nodeWidth = maxCrossSize + innerLeft + innerRight
1557
+ }
1558
+ }
1559
+
1560
+ // Re-apply min/max constraints after any shrink-wrap adjustments
1561
+ // This ensures containers don't violate their constraints after auto-sizing
1562
+ nodeWidth = applyMinMax(nodeWidth, style.minWidth, style.maxWidth, availableWidth)
1563
+ nodeHeight = applyMinMax(nodeHeight, style.minHeight, style.maxHeight, availableHeight)
1564
+
1565
+ // Re-enforce box model constraint: minimum size = padding + border
1566
+ // This must be applied AFTER applyMinMax since min/max can't reduce below padding+border
1567
+ if (!Number.isNaN(nodeWidth) && nodeWidth < minInnerWidth) {
1568
+ nodeWidth = minInnerWidth
1569
+ }
1570
+ if (!Number.isNaN(nodeHeight) && nodeHeight < minInnerHeight) {
1571
+ nodeHeight = minInnerHeight
1572
+ }
1573
+
1574
+ // Set this node's layout using edge-based rounding (Yoga-compatible)
1575
+ // Use parentPosOffsetX/Y computed earlier (includes position offsets)
1576
+ // Compute absolute positions for edge-based rounding
1577
+ const absNodeLeft = absX + marginLeft + parentPosOffsetX
1578
+ const absNodeTop = absY + marginTop + parentPosOffsetY
1579
+ const absNodeRight = absNodeLeft + nodeWidth
1580
+ const absNodeBottom = absNodeTop + nodeHeight
1581
+
1582
+ // Round edges and derive sizes (Yoga algorithm)
1583
+ const roundedAbsLeft = Math.round(absNodeLeft)
1584
+ const roundedAbsTop = Math.round(absNodeTop)
1585
+ const roundedAbsRight = Math.round(absNodeRight)
1586
+ const roundedAbsBottom = Math.round(absNodeBottom)
1587
+
1588
+ layout.width = roundedAbsRight - roundedAbsLeft
1589
+ layout.height = roundedAbsBottom - roundedAbsTop
1590
+ // Position is relative to parent, derived from absolute rounding
1591
+ const roundedAbsParentLeft = Math.round(absX)
1592
+ const roundedAbsParentTop = Math.round(absY)
1593
+ layout.left = roundedAbsLeft - roundedAbsParentLeft
1594
+ layout.top = roundedAbsTop - roundedAbsParentTop
1595
+
1596
+ // Layout absolute children - handle left/right/top/bottom offsets
1597
+ // Absolute positioning uses the PADDING BOX as the containing block
1598
+ // (inside border but INCLUDING padding, not the content box)
1599
+ const absInnerLeft = borderLeft
1600
+ const absInnerTop = borderTop
1601
+ const absInnerRight = borderRight
1602
+ const absInnerBottom = borderBottom
1603
+ const absPaddingBoxW = nodeWidth - absInnerLeft - absInnerRight
1604
+ const absPaddingBoxH = nodeHeight - absInnerTop - absInnerBottom
1605
+ // Content box dimensions for percentage resolution of absolute children
1606
+ const absContentBoxW = absPaddingBoxW - paddingLeft - paddingRight
1607
+ const absContentBoxH = absPaddingBoxH - paddingTop - paddingBottom
1608
+
1609
+ for (const child of absoluteChildren) {
1610
+ const childStyle = child.style
1611
+ // CSS spec: percentage margins resolve against containing block's WIDTH only
1612
+ // Use resolveEdgeValue to respect logical EDGE_START/END
1613
+ const childMarginLeft = resolveEdgeValue(childStyle.margin, 0, style.flexDirection, nodeWidth, direction)
1614
+ const childMarginTop = resolveEdgeValue(childStyle.margin, 1, style.flexDirection, nodeWidth, direction)
1615
+ const childMarginRight = resolveEdgeValue(childStyle.margin, 2, style.flexDirection, nodeWidth, direction)
1616
+ const childMarginBottom = resolveEdgeValue(childStyle.margin, 3, style.flexDirection, nodeWidth, direction)
1617
+
1618
+ // Position offsets from setPosition(edge, value)
1619
+ const leftPos = childStyle.position[0]
1620
+ const topPos = childStyle.position[1]
1621
+ const rightPos = childStyle.position[2]
1622
+ const bottomPos = childStyle.position[3]
1623
+
1624
+ const hasLeft = leftPos.unit !== C.UNIT_UNDEFINED
1625
+ const hasRight = rightPos.unit !== C.UNIT_UNDEFINED
1626
+ const hasTop = topPos.unit !== C.UNIT_UNDEFINED
1627
+ const hasBottom = bottomPos.unit !== C.UNIT_UNDEFINED
1628
+
1629
+ const leftOffset = resolveValue(leftPos, nodeWidth)
1630
+ const topOffset = resolveValue(topPos, nodeHeight)
1631
+ const rightOffset = resolveValue(rightPos, nodeWidth)
1632
+ const bottomOffset = resolveValue(bottomPos, nodeHeight)
1633
+
1634
+ // Calculate available size for absolute child using padding box
1635
+ const contentW = absPaddingBoxW
1636
+ const contentH = absPaddingBoxH
1637
+
1638
+ // Determine child width
1639
+ // - If both left and right set with auto width: stretch to fill
1640
+ // - If auto width but NOT both left and right: shrink to intrinsic (NaN)
1641
+ // - For percentage width: resolve against content box
1642
+ // - Otherwise (explicit width): use available width as constraint
1643
+ let childAvailWidth: number
1644
+ const widthIsAuto = childStyle.width.unit === C.UNIT_AUTO || childStyle.width.unit === C.UNIT_UNDEFINED
1645
+ const widthIsPercent = childStyle.width.unit === C.UNIT_PERCENT
1646
+ if (widthIsAuto && hasLeft && hasRight) {
1647
+ childAvailWidth = contentW - leftOffset - rightOffset - childMarginLeft - childMarginRight
1648
+ } else if (widthIsAuto) {
1649
+ childAvailWidth = NaN // Shrink to intrinsic size
1650
+ } else if (widthIsPercent) {
1651
+ // Percentage widths resolve against content box (inside padding)
1652
+ childAvailWidth = absContentBoxW
1653
+ } else {
1654
+ childAvailWidth = contentW
1655
+ }
1656
+
1657
+ // Determine child height
1658
+ // - If both top and bottom set with auto height: stretch to fill
1659
+ // - If auto height but NOT both top and bottom: shrink to intrinsic (NaN)
1660
+ // - For percentage height: resolve against content box
1661
+ // - Otherwise (explicit height): use available height as constraint
1662
+ let childAvailHeight: number
1663
+ const heightIsAuto = childStyle.height.unit === C.UNIT_AUTO || childStyle.height.unit === C.UNIT_UNDEFINED
1664
+ const heightIsPercent = childStyle.height.unit === C.UNIT_PERCENT
1665
+ if (heightIsAuto && hasTop && hasBottom) {
1666
+ childAvailHeight = contentH - topOffset - bottomOffset - childMarginTop - childMarginBottom
1667
+ } else if (heightIsAuto) {
1668
+ childAvailHeight = NaN // Shrink to intrinsic size
1669
+ } else if (heightIsPercent) {
1670
+ // Percentage heights resolve against content box (inside padding)
1671
+ childAvailHeight = absContentBoxH
1672
+ } else {
1673
+ childAvailHeight = contentH
1674
+ }
1675
+
1676
+ // Compute child position
1677
+ let childX = childMarginLeft + leftOffset
1678
+ let childY = childMarginTop + topOffset
1679
+
1680
+ // First, layout the child to get its dimensions
1681
+ // Use padding box origin (absInnerLeft/Top = border only)
1682
+ // Compute child's absolute position (margin box start, before child's own margin)
1683
+ // Parent's padding box = absX + marginLeft + borderLeft = absX + marginLeft + absInnerLeft
1684
+ // Child's margin box = parent's padding box + leftOffset
1685
+ const childAbsX = absX + marginLeft + absInnerLeft + leftOffset
1686
+ const childAbsY = absY + marginTop + absInnerTop + topOffset
1687
+ // Preserve NaN for shrink-wrap mode - only clamp real numbers to 0
1688
+ const clampIfNumber = (v: number) => (Number.isNaN(v) ? NaN : Math.max(0, v))
1689
+ layoutNode(
1690
+ child,
1691
+ clampIfNumber(childAvailWidth),
1692
+ clampIfNumber(childAvailHeight),
1693
+ layout.left + absInnerLeft + childX,
1694
+ layout.top + absInnerTop + childY,
1695
+ childAbsX,
1696
+ childAbsY,
1697
+ direction,
1698
+ )
1699
+
1700
+ // Now compute final position based on right/bottom if left/top not set
1701
+ const childWidth = child.layout.width
1702
+ const childHeight = child.layout.height
1703
+
1704
+ // Apply alignment when no explicit position set
1705
+ // For absolute children, align-items/justify-content apply when no position offsets
1706
+ if (!hasLeft && !hasRight) {
1707
+ // No horizontal position - use align-items (for row) or justify-content (for column)
1708
+ // Default column direction: cross-axis is horizontal, use alignItems
1709
+ let alignment = style.alignItems
1710
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1711
+ alignment = childStyle.alignSelf
1712
+ }
1713
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1714
+ switch (alignment) {
1715
+ case C.ALIGN_CENTER:
1716
+ childX = childMarginLeft + freeSpaceX / 2
1717
+ break
1718
+ case C.ALIGN_FLEX_END:
1719
+ childX = childMarginLeft + freeSpaceX
1720
+ break
1721
+ case C.ALIGN_STRETCH:
1722
+ // Stretch: already handled by setting width to fill
1723
+ break
1724
+ default: // FLEX_START
1725
+ childX = childMarginLeft
1726
+ break
1727
+ }
1728
+ } else if (!hasLeft && hasRight) {
1729
+ // Position from right edge
1730
+ childX = contentW - rightOffset - childMarginRight - childWidth
1731
+ } else if (hasLeft && hasRight && widthIsAuto) {
1732
+ // Stretch width already handled above
1733
+ child.layout.width = Math.round(childAvailWidth)
1734
+ }
1735
+
1736
+ if (!hasTop && !hasBottom) {
1737
+ // No vertical position - use justify-content (for row) or align-items (for column)
1738
+ // Default column direction: main-axis is vertical, use justifyContent
1739
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1740
+ switch (style.justifyContent) {
1741
+ case C.JUSTIFY_CENTER:
1742
+ childY = childMarginTop + freeSpaceY / 2
1743
+ break
1744
+ case C.JUSTIFY_FLEX_END:
1745
+ childY = childMarginTop + freeSpaceY
1746
+ break
1747
+ default: // FLEX_START
1748
+ childY = childMarginTop
1749
+ break
1750
+ }
1751
+ } else if (!hasTop && hasBottom) {
1752
+ // Position from bottom edge
1753
+ childY = contentH - bottomOffset - childMarginBottom - childHeight
1754
+ } else if (hasTop && hasBottom && heightIsAuto) {
1755
+ // Stretch height already handled above
1756
+ child.layout.height = Math.round(childAvailHeight)
1757
+ }
1758
+
1759
+ // Set final position (relative to container padding box)
1760
+ child.layout.left = Math.round(absInnerLeft + childX)
1761
+ child.layout.top = Math.round(absInnerTop + childY)
1762
+ }
1763
+ }
1764
+
1765
+ // ============================================================================
1766
+ // Layout Stats (stubs for API compatibility with zero-alloc version)
1767
+ // ============================================================================
1768
+ // The classic algorithm doesn't track detailed stats, but we export stubs
1769
+ // so the public API remains consistent between versions.
1770
+
1771
+ export let layoutNodeCalls = 0
1772
+ export let resolveEdgeCalls = 0
1773
+ export let layoutSizingCalls = 0
1774
+ export let layoutPositioningCalls = 0
1775
+ export let layoutCacheHits = 0
1776
+
1777
+ export function resetLayoutStats(): void {
1778
+ layoutNodeCalls = 0
1779
+ resolveEdgeCalls = 0
1780
+ layoutSizingCalls = 0
1781
+ layoutPositioningCalls = 0
1782
+ layoutCacheHits = 0
1783
+ }