flexily 0.2.0 → 0.3.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 (49) hide show
  1. package/README.md +14 -16
  2. package/dist/classic/layout.d.ts.map +1 -1
  3. package/dist/classic/layout.js +10 -1
  4. package/dist/classic/layout.js.map +1 -1
  5. package/dist/classic/node.js +1 -1
  6. package/dist/constants.d.ts +1 -0
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +2 -1
  9. package/dist/constants.js.map +1 -1
  10. package/dist/index-classic.d.ts +1 -1
  11. package/dist/index-classic.d.ts.map +1 -1
  12. package/dist/index-classic.js +5 -5
  13. package/dist/index-classic.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/layout-helpers.d.ts +1 -4
  19. package/dist/layout-helpers.d.ts.map +1 -1
  20. package/dist/layout-helpers.js +2 -7
  21. package/dist/layout-helpers.js.map +1 -1
  22. package/dist/layout-zero.js +195 -39
  23. package/dist/layout-zero.js.map +1 -1
  24. package/dist/logger.js +2 -2
  25. package/dist/logger.js.map +1 -1
  26. package/dist/node-zero.js +1 -1
  27. package/dist/testing.js +4 -4
  28. package/dist/trace.js +1 -1
  29. package/dist/types.js +2 -2
  30. package/dist/types.js.map +1 -1
  31. package/dist/utils.d.ts +11 -3
  32. package/dist/utils.d.ts.map +1 -1
  33. package/dist/utils.js +46 -21
  34. package/dist/utils.js.map +1 -1
  35. package/package.json +11 -3
  36. package/src/CLAUDE.md +36 -21
  37. package/src/classic/layout.ts +105 -45
  38. package/src/classic/node.ts +60 -0
  39. package/src/constants.ts +2 -1
  40. package/src/index-classic.ts +1 -1
  41. package/src/index.ts +1 -1
  42. package/src/layout-flex-lines.ts +70 -3
  43. package/src/layout-helpers.ts +27 -7
  44. package/src/layout-stats.ts +0 -2
  45. package/src/layout-zero.ts +587 -160
  46. package/src/node-zero.ts +98 -2
  47. package/src/testing.ts +20 -14
  48. package/src/types.ts +22 -15
  49. package/src/utils.ts +47 -21
@@ -37,7 +37,6 @@ export { markSubtreeLayoutSeen, countNodes } from "./layout-traversal.js"
37
37
  export {
38
38
  layoutNodeCalls,
39
39
  measureNodeCalls,
40
- resolveEdgeCalls,
41
40
  layoutSizingCalls,
42
41
  layoutPositioningCalls,
43
42
  layoutCacheHits,
@@ -52,6 +51,7 @@ import {
52
51
  isRowDirection,
53
52
  isReverseDirection,
54
53
  resolveEdgeValue,
54
+ resolvePositionEdge,
55
55
  isEdgeAuto,
56
56
  resolveEdgeBorderValue,
57
57
  } from "./layout-helpers.js"
@@ -73,6 +73,8 @@ import {
73
73
  _lineItemSpacings,
74
74
  breakIntoLines,
75
75
  distributeFlexSpaceForLine,
76
+ enterLayout,
77
+ exitLayout,
76
78
  } from "./layout-flex-lines.js"
77
79
 
78
80
  /**
@@ -84,12 +86,19 @@ export function computeLayout(
84
86
  availableHeight: number,
85
87
  direction: number = C.DIRECTION_LTR,
86
88
  ): void {
87
- resetLayoutStats()
88
- getTrace()?.resetCounter()
89
- // Clear layout cache from previous pass (important for correct layout after tree changes)
90
- root.resetLayoutCache()
91
- // Pass absolute position (0,0) for root node - used for Yoga-compatible edge rounding
92
- layoutNode(root, availableWidth, availableHeight, 0, 0, 0, 0, direction)
89
+ // Save line state if re-entrant (nested calculateLayout from measureFunc)
90
+ const saved = enterLayout()
91
+ try {
92
+ resetLayoutStats()
93
+ getTrace()?.resetCounter()
94
+ // Clear layout cache from previous pass (important for correct layout after tree changes)
95
+ root.resetLayoutCache()
96
+ // Pass absolute position (0,0) for root node - used for Yoga-compatible edge rounding
97
+ layoutNode(root, availableWidth, availableHeight, 0, 0, 0, 0, direction)
98
+ } finally {
99
+ // Restore line state for outer pass (no-op at depth 0)
100
+ exitLayout(saved)
101
+ }
93
102
  }
94
103
 
95
104
  /**
@@ -153,7 +162,9 @@ function layoutNode(
153
162
  !node.isDirty() &&
154
163
  Object.is(flex.lastAvailW, availableWidth) &&
155
164
  Object.is(flex.lastAvailH, availableHeight) &&
156
- flex.lastDir === direction
165
+ flex.lastDir === direction &&
166
+ flex.lastAbsX === absX &&
167
+ flex.lastAbsY === absY
157
168
  ) {
158
169
  // Constraints unchanged - just update position based on offset delta
159
170
  _t?.fingerprintHit(_tn, availableWidth, availableHeight)
@@ -175,6 +186,8 @@ function layoutNode(
175
186
  sameW: Object.is(flex.lastAvailW, availableWidth),
176
187
  sameH: Object.is(flex.lastAvailH, availableHeight),
177
188
  sameDir: flex.lastDir === direction,
189
+ sameAbsX: flex.lastAbsX === absX,
190
+ sameAbsY: flex.lastAbsY === absY,
178
191
  })
179
192
 
180
193
  // ============================================================================
@@ -278,10 +291,13 @@ function layoutNode(
278
291
  // This ensures children's absolute positions include parent's position offset
279
292
  let parentPosOffsetX = 0
280
293
  let parentPosOffsetY = 0
281
- if (style.positionType === C.POSITION_TYPE_STATIC || style.positionType === C.POSITION_TYPE_RELATIVE) {
282
- const leftPos = style.position[0]
294
+ // CSS spec: position:static ignores insets (top/left/right/bottom).
295
+ // Only position:relative applies insets as offsets from normal flow position.
296
+ if (style.positionType === C.POSITION_TYPE_RELATIVE) {
297
+ // Resolve logical EDGE_START/EDGE_END to physical left/right based on direction
298
+ const leftPos = resolvePositionEdge(style.position, 0, direction)
283
299
  const topPos = style.position[1]
284
- const rightPos = style.position[2]
300
+ const rightPos = resolvePositionEdge(style.position, 2, direction)
285
301
  const bottomPos = style.position[3]
286
302
 
287
303
  if (leftPos.unit !== C.UNIT_UNDEFINED) {
@@ -445,29 +461,52 @@ function layoutNode(
445
461
  baseSize = sizeVal.value
446
462
  } else if (sizeVal.unit === C.UNIT_PERCENT) {
447
463
  baseSize = Number.isNaN(mainAxisSize) ? 0 : mainAxisSize * (sizeVal.value / 100)
448
- } else if (child.hasMeasureFunc() && childStyle.flexGrow === 0) {
449
- // For auto-sized children with measureFunc but no flexGrow,
450
- // pre-measure to get intrinsic size for justify-content calculation
451
- // Use cached margins
464
+ } else if (child.hasMeasureFunc()) {
465
+ // For auto-sized children with measureFunc,
466
+ // pre-measure to get intrinsic content size as flex base size.
467
+ // CSS spec section 9.2: flex base size is content-based regardless of flexGrow.
468
+ //
469
+ // When flexGrow > 0, measure UNCONSTRAINED on main axis to get max-content
470
+ // size. This ensures proportional distribution based on content (e.g., two
471
+ // text nodes with widths 10 and 20 get proportional shares of free space).
472
+ // When flexGrow === 0, measure AT_MOST container (original behavior for
473
+ // justify-content calculation).
452
474
  const crossMargin = isRow ? cflex.marginT + cflex.marginB : cflex.marginL + cflex.marginR
453
475
  const availCross = crossAxisSize - crossMargin
454
- // Use cached measure to avoid redundant calls within a layout pass
455
476
  // Measure function takes PHYSICAL (width, height), not logical (main, cross).
456
477
  // For row: main=width, cross=height. For column: main=height, cross=width.
457
- const mW = isRow ? mainAxisSize : availCross
458
- const mH = isRow ? availCross : mainAxisSize
478
+ const wantMaxContent = childStyle.flexGrow > 0
479
+ const mW = isRow
480
+ ? wantMaxContent
481
+ ? Infinity
482
+ : Number.isNaN(mainAxisSize)
483
+ ? Infinity
484
+ : mainAxisSize
485
+ : Number.isNaN(availCross)
486
+ ? Infinity
487
+ : availCross
488
+ const mH = isRow
489
+ ? Number.isNaN(availCross)
490
+ ? Infinity
491
+ : availCross
492
+ : wantMaxContent
493
+ ? Infinity
494
+ : Number.isNaN(mainAxisSize)
495
+ ? Infinity
496
+ : mainAxisSize
459
497
  const mWMode = isRow
460
- ? C.MEASURE_MODE_AT_MOST
498
+ ? wantMaxContent
499
+ ? C.MEASURE_MODE_UNDEFINED
500
+ : C.MEASURE_MODE_AT_MOST
461
501
  : Number.isNaN(availCross)
462
502
  ? C.MEASURE_MODE_UNDEFINED
463
503
  : C.MEASURE_MODE_AT_MOST
464
- const mHMode = isRow ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_AT_MOST
465
- const measured = child.cachedMeasure(
466
- Number.isNaN(mW) ? Infinity : mW,
467
- mWMode,
468
- Number.isNaN(mH) ? Infinity : mH,
469
- mHMode,
470
- )!
504
+ const mHMode = isRow
505
+ ? C.MEASURE_MODE_UNDEFINED
506
+ : wantMaxContent
507
+ ? C.MEASURE_MODE_UNDEFINED
508
+ : C.MEASURE_MODE_AT_MOST
509
+ const measured = child.cachedMeasure(mW, mWMode, mH, mHMode)!
471
510
  baseSize = isRow ? measured.width : measured.height
472
511
  } else if (child.children.length > 0) {
473
512
  // For auto-sized children WITH children but no measureFunc,
@@ -478,8 +517,10 @@ function layoutNode(
478
517
  const cached = child.getCachedLayout(sizingW, sizingH)
479
518
  if (cached) {
480
519
  incLayoutCacheHits()
520
+ _t?.cacheHit(_tn, sizingW, sizingH, cached.width, cached.height)
481
521
  baseSize = isRow ? cached.width : cached.height
482
522
  } else {
523
+ _t?.cacheMiss(_tn, sizingW, sizingH)
483
524
  // Use measureNode for sizing-only pass (faster than full layoutNode)
484
525
  // Save layout before measureNode — it overwrites node.layout.width/height
485
526
  // with intrinsic measurements (unconstrained widths -> text doesn't wrap ->
@@ -487,11 +528,12 @@ function layoutNode(
487
528
  // in Phase 9 would skip re-computation and preserve the corrupted values.
488
529
  const savedW = child.layout.width
489
530
  const savedH = child.layout.height
490
- measureNode(child, sizingW, sizingH)
531
+ measureNode(child, sizingW, sizingH, direction)
491
532
  const measuredW = child.layout.width
492
533
  const measuredH = child.layout.height
493
534
  child.layout.width = savedW
494
535
  child.layout.height = savedH
536
+ _t?.measureSaveRestore(_tn, savedW, savedH, measuredW, measuredH)
495
537
  baseSize = isRow ? measuredW : measuredH
496
538
  // Cache the result for potential reuse
497
539
  child.setCachedLayout(sizingW, sizingH, measuredW, measuredH)
@@ -529,8 +571,16 @@ function layoutNode(
529
571
  // below content size. Yoga defaults flexShrink to 0, preventing this. For
530
572
  // overflow:hidden/scroll children, ensure flexShrink >= 1 so they participate
531
573
  // in negative free space distribution (matching CSS behavior).
532
- cflex.flexShrink =
533
- childStyle.overflow !== C.OVERFLOW_VISIBLE ? Math.max(childStyle.flexShrink, 1) : childStyle.flexShrink
574
+ //
575
+ // Measured items with flexGrow > 0 use max-content as base size (CSS section 9.2).
576
+ // When their total base sizes exceed the container, they must be shrinkable so
577
+ // the flex algorithm can distribute negative free space. Without this, a single
578
+ // flexGrow text node whose content exceeds the container would overflow instead
579
+ // of filling the remaining space.
580
+ let shrink = childStyle.flexShrink
581
+ if (childStyle.overflow !== C.OVERFLOW_VISIBLE) shrink = Math.max(shrink, 1)
582
+ if (child.hasMeasureFunc() && childStyle.flexGrow > 0) shrink = Math.max(shrink, 1)
583
+ cflex.flexShrink = shrink
534
584
 
535
585
  // Store base and main size (start from base size - distribution happens from here)
536
586
  cflex.baseSize = baseSize
@@ -722,9 +772,15 @@ function layoutNode(
722
772
  // For ALIGN_BASELINE in row direction, we need to know the max baseline first
723
773
  // Zero-alloc: store baseline in child.flex.baseline, not a temporary array
724
774
  let maxBaseline = 0
775
+ // baselineZoneHeight: the effective cross-axis size that non-baseline children
776
+ // align within when baseline alignment is present. This is max(maxBaseline, maxChildHeight).
777
+ // Only meaningful when alignItems != ALIGN_BASELINE but some children have alignSelf=baseline.
778
+ let baselineZoneHeight = 0
779
+ const alignItemsIsBaseline = style.alignItems === C.ALIGN_BASELINE
725
780
 
726
781
  if (hasBaselineAlignment && isRow) {
727
782
  // First pass: compute each child's baseline and find the maximum
783
+ let maxChildHeight = 0
728
784
  for (const child of node.children) {
729
785
  if (child.flex.relativeIndex < 0) continue
730
786
  const childStyle = child.style
@@ -760,20 +816,23 @@ function layoutNode(
760
816
  const cached = child.getCachedLayout(child.flex.mainSize, NaN)
761
817
  if (cached) {
762
818
  incLayoutCacheHits()
819
+ _t?.cacheHit(_tn, child.flex.mainSize, NaN, cached.width, cached.height)
763
820
  childWidth = cached.width
764
821
  childHeight = cached.height
765
822
  } else {
823
+ _t?.cacheMiss(_tn, child.flex.mainSize, NaN)
766
824
  // Use measureNode for sizing-only pass (faster than full layoutNode)
767
825
  // Save layout before measureNode — it overwrites node.layout.width/height
768
826
  // with intrinsic measurements. Without save/restore, layoutNode's fingerprint
769
827
  // check in Phase 9 would skip re-computation and preserve corrupted values.
770
828
  const savedW = child.layout.width
771
829
  const savedH = child.layout.height
772
- measureNode(child, child.flex.mainSize, NaN)
830
+ measureNode(child, child.flex.mainSize, NaN, direction)
773
831
  childWidth = child.layout.width
774
832
  childHeight = child.layout.height
775
833
  child.layout.width = savedW
776
834
  child.layout.height = savedH
835
+ _t?.measureSaveRestore(_tn, savedW, savedH, childWidth, childHeight)
777
836
  child.setCachedLayout(child.flex.mainSize, NaN, childWidth, childHeight)
778
837
  }
779
838
  }
@@ -789,8 +848,23 @@ function layoutNode(
789
848
  // This is a simplification from CSS spec but acceptable for TUI use cases
790
849
  child.flex.baseline = topMargin + childHeight
791
850
  }
792
- maxBaseline = Math.max(maxBaseline, child.flex.baseline)
851
+
852
+ // Track max child height (including margin) for baseline zone calculation
853
+ maxChildHeight = Math.max(maxChildHeight, topMargin + childHeight + child.flex.marginB)
854
+
855
+ // When alignItems is baseline, ALL children participate in baseline computation.
856
+ // When alignItems is NOT baseline, only children with alignSelf=baseline participate.
857
+ // This matches Yoga's behavior: non-baseline children are positioned within the
858
+ // "baseline zone" (the effective height determined by baseline-aligned children),
859
+ // not the full container cross-axis.
860
+ if (alignItemsIsBaseline || childStyle.alignSelf === C.ALIGN_BASELINE) {
861
+ maxBaseline = Math.max(maxBaseline, child.flex.baseline)
862
+ }
793
863
  }
864
+
865
+ // Baseline zone height: the max of maxBaseline and the tallest child.
866
+ // Non-baseline children are aligned within this zone, not the full container.
867
+ baselineZoneHeight = Math.max(maxBaseline, maxChildHeight)
794
868
  }
795
869
 
796
870
  // -----------------------------------------------------------------------
@@ -827,16 +901,49 @@ function layoutNode(
827
901
  childCross = crossDim.value
828
902
  } else if (crossDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
829
903
  childCross = crossAxisSize * (crossDim.value / 100)
830
- } else {
831
- // Auto - use a default or measure. For now, use 0 and let stretch handle it.
832
- childCross = 0
904
+ } else if (child.hasMeasureFunc()) {
905
+ // Auto-sized with measureFunc: get tentative cross size from cached measure.
906
+ // Phase 5 already called cachedMeasure, so this is typically a cache hit (no alloc).
907
+ const crossMargin = crossMarginStart + crossMarginEnd
908
+ const availCross = Number.isNaN(crossAxisSize) ? Infinity : crossAxisSize - crossMargin
909
+ // Use child's resolved mainSize (from flex distribution) instead of parent's
910
+ // mainAxisSize. After Phase 6a, child.flex.mainSize may be smaller than
911
+ // mainAxisSize (e.g., due to wrapping). Measuring at parent width would
912
+ // underestimate text wrapping height (fewer lines → shorter cross size).
913
+ const childMainSize = child.flex.mainSize
914
+ const mW = isRow ? childMainSize : availCross
915
+ const mH = isRow ? availCross : childMainSize
916
+ const mWMode = Number.isNaN(mW) ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_AT_MOST
917
+ const mHMode = Number.isNaN(mH) ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_AT_MOST
918
+ const measured = child.cachedMeasure(
919
+ Number.isNaN(mW) ? Infinity : mW,
920
+ mWMode,
921
+ Number.isNaN(mH) ? Infinity : mH,
922
+ mHMode,
923
+ )
924
+ if (measured) {
925
+ childCross = isRow ? measured.height : measured.width
926
+ }
927
+ } else if (child.children.length > 0) {
928
+ // Auto-sized container children (no measureFunc, has children):
929
+ // Compute intrinsic cross-axis size by measuring with unconstrained
930
+ // dimensions. This gives the shrink-wrap size, which is what we need
931
+ // for line cross-size estimation. Passing NaN for both axes ensures
932
+ // measureNode returns the content-determined size rather than
933
+ // filling the available space.
934
+ const savedW = child.layout.width
935
+ const savedH = child.layout.height
936
+ measureNode(child, NaN, NaN, direction)
937
+ childCross = isRow ? child.layout.height : child.layout.width
938
+ child.layout.width = savedW
939
+ child.layout.height = savedH
833
940
  }
834
941
  maxLineCross = Math.max(maxLineCross, childCross + crossMarginStart + crossMarginEnd)
835
942
  }
836
- // Fallback cross size: use measured max, or divide available space among lines
837
- // Guard against NaN/division-by-zero: if crossAxisSize is NaN or numLines is 0, use 0
838
- const fallbackCross = numLines > 0 && !Number.isNaN(crossAxisSize) ? crossAxisSize / numLines : 0
839
- const lineCrossSize = maxLineCross > 0 ? maxLineCross : fallbackCross
943
+ // Use measured max cross size. If all children are auto-sized (maxLineCross === 0),
944
+ // use 0 NOT crossAxisSize/numLines, which would consume all free space and
945
+ // prevent alignContent from distributing it. Actual sizes are computed in Phase 8.
946
+ const lineCrossSize = maxLineCross
840
947
  _lineCrossSizes[lineIdx] = lineCrossSize
841
948
  cumulativeCrossOffset += lineCrossSize + crossGap
842
949
  }
@@ -855,62 +962,89 @@ function layoutNode(
855
962
  const freeSpace = crossAxisSize - totalLineCrossSize
856
963
  const alignContent = style.alignContent
857
964
 
858
- // Reset offsets based on alignContent
859
- if (freeSpace > 0 || alignContent === C.ALIGN_STRETCH) {
860
- switch (alignContent) {
861
- case C.ALIGN_FLEX_END:
862
- // Lines packed at end
965
+ // Apply alignContent offset based on free space.
966
+ // flex-end and center apply with both positive and negative free space.
967
+ // space-between/around/evenly only distribute with positive free space;
968
+ // with negative space they collapse to flex-start or center (CSS spec).
969
+ // stretch only expands lines with positive free space.
970
+ switch (alignContent) {
971
+ case C.ALIGN_FLEX_END:
972
+ // Lines packed at end (works with negative free space too — shifts lines up)
973
+ for (let i = 0; i < numLines; i++) {
974
+ _lineCrossOffsets[i]! += freeSpace
975
+ }
976
+ break
977
+
978
+ case C.ALIGN_CENTER:
979
+ // Lines centered (works with negative free space — equal overflow both sides)
980
+ {
981
+ const centerOffset = freeSpace / 2
863
982
  for (let i = 0; i < numLines; i++) {
864
- _lineCrossOffsets[i]! += freeSpace
983
+ _lineCrossOffsets[i]! += centerOffset
865
984
  }
866
- break
985
+ }
986
+ break
867
987
 
868
- case C.ALIGN_CENTER:
869
- // Lines centered
870
- {
871
- const centerOffset = freeSpace / 2
872
- for (let i = 0; i < numLines; i++) {
873
- _lineCrossOffsets[i]! += centerOffset
874
- }
988
+ case C.ALIGN_SPACE_BETWEEN:
989
+ // First line at start, last at end, evenly distributed
990
+ // With negative free space: collapses to flex-start (no adjustment)
991
+ if (freeSpace > 0 && numLines > 1) {
992
+ const gap = freeSpace / (numLines - 1)
993
+ for (let i = 1; i < numLines; i++) {
994
+ _lineCrossOffsets[i]! += gap * i
875
995
  }
876
- break
996
+ }
997
+ break
877
998
 
878
- case C.ALIGN_SPACE_BETWEEN:
879
- // First line at start, last at end, evenly distributed
880
- if (numLines > 1) {
881
- const gap = freeSpace / (numLines - 1)
882
- for (let i = 1; i < numLines; i++) {
883
- _lineCrossOffsets[i]! += gap * i
884
- }
999
+ case C.ALIGN_SPACE_AROUND:
1000
+ // Even spacing with half-space at edges
1001
+ // With negative free space: collapses to center (CSS spec)
1002
+ if (freeSpace > 0) {
1003
+ const halfGap = freeSpace / (numLines * 2)
1004
+ for (let i = 0; i < numLines; i++) {
1005
+ _lineCrossOffsets[i]! += halfGap + halfGap * 2 * i
885
1006
  }
886
- break
1007
+ } else {
1008
+ // Negative space: center fallback
1009
+ const centerOffset = freeSpace / 2
1010
+ for (let i = 0; i < numLines; i++) {
1011
+ _lineCrossOffsets[i]! += centerOffset
1012
+ }
1013
+ }
1014
+ break
887
1015
 
888
- case C.ALIGN_SPACE_AROUND:
889
- // Even spacing with half-space at edges
890
- {
891
- const halfGap = freeSpace / (numLines * 2)
892
- for (let i = 0; i < numLines; i++) {
893
- _lineCrossOffsets[i]! += halfGap + halfGap * 2 * i
894
- }
1016
+ case C.ALIGN_SPACE_EVENLY:
1017
+ // Equal spacing between lines and at edges
1018
+ // With negative free space: collapses to center (CSS spec)
1019
+ if (freeSpace > 0 && numLines > 0) {
1020
+ const gap = freeSpace / (numLines + 1)
1021
+ for (let i = 0; i < numLines; i++) {
1022
+ _lineCrossOffsets[i]! += gap * (i + 1)
895
1023
  }
896
- break
1024
+ } else if (freeSpace < 0) {
1025
+ // Negative space: center fallback
1026
+ const centerOffset = freeSpace / 2
1027
+ for (let i = 0; i < numLines; i++) {
1028
+ _lineCrossOffsets[i]! += centerOffset
1029
+ }
1030
+ }
1031
+ break
897
1032
 
898
- case C.ALIGN_STRETCH:
899
- // Distribute extra space evenly among lines
900
- if (freeSpace > 0 && numLines > 0) {
901
- const extraPerLine = freeSpace / numLines
902
- for (let i = 0; i < numLines; i++) {
903
- _lineCrossSizes[i]! += extraPerLine
904
- // Recalculate offset for subsequent lines
905
- if (i > 0) {
906
- _lineCrossOffsets[i] = _lineCrossOffsets[i - 1]! + _lineCrossSizes[i - 1]! + crossGap
907
- }
1033
+ case C.ALIGN_STRETCH:
1034
+ // Distribute extra space evenly among lines (only with positive free space)
1035
+ if (freeSpace > 0 && numLines > 0) {
1036
+ const extraPerLine = freeSpace / numLines
1037
+ for (let i = 0; i < numLines; i++) {
1038
+ _lineCrossSizes[i]! += extraPerLine
1039
+ // Recalculate offset for subsequent lines
1040
+ if (i > 0) {
1041
+ _lineCrossOffsets[i] = _lineCrossOffsets[i - 1]! + _lineCrossSizes[i - 1]! + crossGap
908
1042
  }
909
1043
  }
910
- break
1044
+ }
1045
+ break
911
1046
 
912
- // ALIGN_FLEX_START is the default - lines already at start
913
- }
1047
+ // ALIGN_FLEX_START is the default - lines already at start
914
1048
  }
915
1049
 
916
1050
  // For wrap-reverse, lines should be positioned from the end of the cross axis
@@ -929,6 +1063,29 @@ function layoutNode(
929
1063
  }
930
1064
  }
931
1065
 
1066
+ // Save line data before Phase 8: recursive layoutNode calls for children
1067
+ // with sub-children overwrite the global pre-allocated _lineCrossSizes,
1068
+ // _lineCrossOffsets, _lineJustifyStarts, and _lineItemSpacings arrays.
1069
+ // For multi-line layouts, we copy the values into small local arrays.
1070
+ // This allocation is rare (only for multi-line wrapping containers) and
1071
+ // tiny (4 arrays x numLines x 8 bytes).
1072
+ let savedLineCrossSizes: Float64Array | null = null
1073
+ let savedLineCrossOffsets: Float64Array | null = null
1074
+ let savedLineJustifyStarts: Float64Array | null = null
1075
+ let savedLineItemSpacings: Float64Array | null = null
1076
+ if (numLines > 1) {
1077
+ savedLineCrossSizes = new Float64Array(numLines)
1078
+ savedLineCrossOffsets = new Float64Array(numLines)
1079
+ savedLineJustifyStarts = new Float64Array(numLines)
1080
+ savedLineItemSpacings = new Float64Array(numLines)
1081
+ for (let i = 0; i < numLines; i++) {
1082
+ savedLineCrossSizes[i] = _lineCrossSizes[i]!
1083
+ savedLineCrossOffsets[i] = _lineCrossOffsets[i]!
1084
+ savedLineJustifyStarts[i] = _lineJustifyStarts[i]!
1085
+ savedLineItemSpacings[i] = _lineItemSpacings[i]!
1086
+ }
1087
+ }
1088
+
932
1089
  // -----------------------------------------------------------------------
933
1090
  // PHASE 8: Position and Layout Children
934
1091
  // -----------------------------------------------------------------------
@@ -989,13 +1146,23 @@ function layoutNode(
989
1146
  lineChildIdx = 0 // Reset position within line
990
1147
  currentLineLength = _lineChildren[childLineIdx]!.length
991
1148
  // Reset mainPos for new line using line-specific justify offset
992
- const lineOffset = _lineJustifyStarts[childLineIdx]!
993
- currentItemSpacing = _lineItemSpacings[childLineIdx]!
1149
+ // Use saved arrays for multi-line to avoid corruption by recursive layoutNode
1150
+ const lineOffset = savedLineJustifyStarts
1151
+ ? savedLineJustifyStarts[childLineIdx]!
1152
+ : _lineJustifyStarts[childLineIdx]!
1153
+ currentItemSpacing = savedLineItemSpacings
1154
+ ? savedLineItemSpacings[childLineIdx]!
1155
+ : _lineItemSpacings[childLineIdx]!
994
1156
  mainPos = effectiveReverse ? effectiveMainAxisSize - lineOffset : lineOffset
995
1157
  }
996
1158
 
997
- // Get cross-axis offset for this child's line (from pre-allocated array)
998
- const lineCrossOffset = childLineIdx < MAX_FLEX_LINES ? _lineCrossOffsets[childLineIdx] : 0
1159
+ // Get cross-axis offset for this child's line
1160
+ // Use saved arrays for multi-line to avoid corruption by recursive layoutNode
1161
+ const lineCrossOffset = savedLineCrossOffsets
1162
+ ? savedLineCrossOffsets[childLineIdx]!
1163
+ : childLineIdx < MAX_FLEX_LINES
1164
+ ? _lineCrossOffsets[childLineIdx]
1165
+ : 0
999
1166
 
1000
1167
  // For main-axis margins, use computed auto margin values
1001
1168
  // For cross-axis margins, use cached values (auto margins on cross axis handled separately)
@@ -1051,6 +1218,24 @@ function layoutNode(
1051
1218
  alignment = childStyle.alignSelf
1052
1219
  }
1053
1220
 
1221
+ // CSS Alignment spec: aspect-ratio fallback alignment
1222
+ // When a flex item has aspect-ratio and auto cross-axis dimension,
1223
+ // the fallback alignment is flex-start (not stretch). This prevents
1224
+ // stretch from overriding the AR-derived dimension.
1225
+ // Only applies when stretch is inherited (align-self: auto), not explicit.
1226
+ const childCrossDimForAR = isRow ? childStyle.height : childStyle.width
1227
+ const childCrossIsAutoForAR =
1228
+ childCrossDimForAR.unit === C.UNIT_AUTO || childCrossDimForAR.unit === C.UNIT_UNDEFINED
1229
+ if (
1230
+ alignment === C.ALIGN_STRETCH &&
1231
+ childStyle.alignSelf === C.ALIGN_AUTO &&
1232
+ !Number.isNaN(childStyle.aspectRatio) &&
1233
+ childStyle.aspectRatio > 0 &&
1234
+ childCrossIsAutoForAR
1235
+ ) {
1236
+ alignment = C.ALIGN_FLEX_START
1237
+ }
1238
+
1054
1239
  // Cross axis size depends on alignment and child's explicit dimensions
1055
1240
  // IMPORTANT: Resolve percent against parent's cross axis, not child's available
1056
1241
  let childCrossSize: number
@@ -1073,8 +1258,16 @@ function layoutNode(
1073
1258
  // Percent of PARENT's cross axis (resolveValue handles NaN -> 0)
1074
1259
  childCrossSize = resolveValue(crossDim, crossAxisSize)
1075
1260
  } else if (parentHasDefiniteCross && alignment === C.ALIGN_STRETCH) {
1076
- // Stretch alignment with definite parent cross size - fill the cross axis
1077
- childCrossSize = crossAxisSize - crossMargin
1261
+ // Stretch alignment with definite parent cross size - fill the line's cross axis
1262
+ // For wrapping layouts, stretch to line cross size, not full container cross size
1263
+ // Use saved arrays for multi-line to avoid corruption by recursive layoutNode
1264
+ const lineCross =
1265
+ numLines > 1
1266
+ ? savedLineCrossSizes
1267
+ ? savedLineCrossSizes[childLineIdx]!
1268
+ : _lineCrossSizes[childLineIdx]!
1269
+ : crossAxisSize
1270
+ childCrossSize = lineCross - crossMargin
1078
1271
  } else {
1079
1272
  // Non-stretch alignment or no definite cross size - shrink-wrap to content
1080
1273
  childCrossSize = NaN
@@ -1100,7 +1293,11 @@ function layoutNode(
1100
1293
  // For auto main size children, use flex-computed size if flexGrow > 0,
1101
1294
  // otherwise pass remaining available space for shrink-wrap behavior
1102
1295
  const mainDim = isRow ? childStyle.width : childStyle.height
1103
- const mainIsAutoChild = mainDim.unit === C.UNIT_AUTO || mainDim.unit === C.UNIT_UNDEFINED
1296
+ // A child has definite main size if it has explicit width/height OR non-auto flexBasis
1297
+ const hasDefiniteFlexBasis =
1298
+ childStyle.flexBasis.unit === C.UNIT_POINT || childStyle.flexBasis.unit === C.UNIT_PERCENT
1299
+ const mainIsAutoChild =
1300
+ (mainDim.unit === C.UNIT_AUTO || mainDim.unit === C.UNIT_UNDEFINED) && !hasDefiniteFlexBasis
1104
1301
  const hasFlexGrow = cflex.flexGrow > 0
1105
1302
  // Use flex-computed mainSize for all cases - it includes padding/border as minimum
1106
1303
  // The flex algorithm already computed the proper size based on content/padding/border
@@ -1177,14 +1374,16 @@ function layoutNode(
1177
1374
  const fractionalLeft = innerLeft + childX
1178
1375
  const fractionalTop = innerTop + childY
1179
1376
 
1180
- // Compute position offsets for RELATIVE/STATIC positioned children
1377
+ // Compute position offsets for RELATIVE positioned children
1378
+ // CSS spec: position:static ignores insets; only position:relative applies them.
1181
1379
  // These must be included in the absolute position BEFORE rounding (Yoga-compatible)
1182
1380
  let posOffsetX = 0
1183
1381
  let posOffsetY = 0
1184
- if (childStyle.positionType === C.POSITION_TYPE_RELATIVE || childStyle.positionType === C.POSITION_TYPE_STATIC) {
1185
- const relLeftPos = childStyle.position[0]
1382
+ if (childStyle.positionType === C.POSITION_TYPE_RELATIVE) {
1383
+ // Resolve logical EDGE_START/EDGE_END to physical left/right based on direction
1384
+ const relLeftPos = resolvePositionEdge(childStyle.position, 0, direction)
1186
1385
  const relTopPos = childStyle.position[1]
1187
- const relRightPos = childStyle.position[2]
1386
+ const relRightPos = resolvePositionEdge(childStyle.position, 2, direction)
1188
1387
  const relBottomPos = childStyle.position[3]
1189
1388
 
1190
1389
  // Left offset (takes precedence over right)
@@ -1251,8 +1450,12 @@ function layoutNode(
1251
1450
  // Calculate child's RELATIVE position (stored in layout)
1252
1451
  // Yoga behavior: position is rounded locally, size uses absolute edge rounding
1253
1452
  // This ensures sizes are pixel-perfect at document level while positions remain intuitive
1254
- const childLeft = Math.round(fractionalLeft + posOffsetX)
1255
- const childTop = Math.round(fractionalTop + posOffsetY)
1453
+ // Yoga 3.x quirk: measureFunc leaf nodes use Math.floor for position rounding,
1454
+ // while explicit-sized children use Math.round. This affects any justify/align mode
1455
+ // that produces fractional offsets (center, space-around, space-evenly).
1456
+ const posRound = shouldMeasure ? Math.floor : Math.round
1457
+ const childLeft = posRound(fractionalLeft + posOffsetX)
1458
+ const childTop = posRound(fractionalTop + posOffsetY)
1256
1459
 
1257
1460
  // Check if cross axis is auto-sized (needed for deciding what to pass to layoutNode)
1258
1461
  const crossDimForLayoutCall = isRow ? childStyle.height : childStyle.width
@@ -1382,7 +1585,18 @@ function layoutNode(
1382
1585
  const crossEndIndex = isRow ? 3 : 2 // bottom for row, right for column
1383
1586
  const hasAutoStartMargin = isEdgeAuto(childStyle.margin, crossStartIndex, style.flexDirection, direction)
1384
1587
  const hasAutoEndMargin = isEdgeAuto(childStyle.margin, crossEndIndex, style.flexDirection, direction)
1385
- const availableCrossSpace = crossAxisSize - finalCrossSize - crossMargin
1588
+ // When baseline alignment is present (hasBaselineAlignment) and this child is NOT using
1589
+ // baseline alignment, align within the baseline zone instead of the full container.
1590
+ // Yoga behavior: non-baseline children are positioned relative to the effective height
1591
+ // of the baseline group (max of maxBaseline and tallest child), not the container.
1592
+ const useBaselineZone =
1593
+ hasBaselineAlignment &&
1594
+ isRow &&
1595
+ !alignItemsIsBaseline &&
1596
+ alignment !== C.ALIGN_BASELINE &&
1597
+ baselineZoneHeight > 0
1598
+ const effectiveCrossSize = useBaselineZone ? baselineZoneHeight : crossAxisSize
1599
+ const availableCrossSpace = effectiveCrossSize - finalCrossSize - crossMargin
1386
1600
 
1387
1601
  if (hasAutoStartMargin && hasAutoEndMargin) {
1388
1602
  // Both auto: center the item
@@ -1415,11 +1629,14 @@ function layoutNode(
1415
1629
  }
1416
1630
  }
1417
1631
 
1418
- if (crossOffset > 0) {
1632
+ if (crossOffset !== 0) {
1633
+ // Yoga 3.x quirk: measureFunc leaf nodes use Math.floor for cross-axis alignment
1634
+ // offset, matching the main-axis floor rounding behavior
1635
+ const crossRound = shouldMeasure ? Math.floor : Math.round
1419
1636
  if (isRow) {
1420
- child.layout.top += Math.round(crossOffset)
1637
+ child.layout.top += crossRound(crossOffset)
1421
1638
  } else {
1422
- child.layout.left += Math.round(crossOffset)
1639
+ child.layout.left += crossRound(crossOffset)
1423
1640
  }
1424
1641
  }
1425
1642
 
@@ -1480,48 +1697,65 @@ function layoutNode(
1480
1697
  }
1481
1698
  actualUsedMain += totalGaps
1482
1699
 
1483
- if (isRow && style.width.unit !== C.UNIT_POINT && style.width.unit !== C.UNIT_PERCENT) {
1700
+ // Skip main-axis shrink-wrap when aspect ratio determined this dimension
1701
+ const hasAR = !Number.isNaN(aspectRatio) && aspectRatio > 0
1702
+ if (isRow && style.width.unit !== C.UNIT_POINT && style.width.unit !== C.UNIT_PERCENT && !hasAR) {
1484
1703
  // Auto-width row: shrink-wrap to content
1485
1704
  nodeWidth = actualUsedMain + innerLeft + innerRight
1486
1705
  }
1487
- if (!isRow && style.height.unit !== C.UNIT_POINT && style.height.unit !== C.UNIT_PERCENT) {
1706
+ if (!isRow && style.height.unit !== C.UNIT_POINT && style.height.unit !== C.UNIT_PERCENT && !hasAR) {
1488
1707
  // Auto-height column: shrink-wrap to content
1489
1708
  nodeHeight = actualUsedMain + innerTop + innerBottom
1490
1709
  }
1491
- // For cross axis, find the max child size
1492
- // CSS spec: percentage margins resolve against containing block's WIDTH only
1493
- // Use resolveEdgeValue to respect logical EDGE_START/END
1494
- let maxCrossSize = 0
1495
- for (const child of node.children) {
1496
- if (child.flex.relativeIndex < 0) continue
1497
- const childCross = isRow ? child.layout.height : child.layout.width
1498
- const childMargin = isRow
1499
- ? resolveEdgeValue(child.style.margin, 1, style.flexDirection, contentWidth, direction) +
1500
- resolveEdgeValue(child.style.margin, 3, style.flexDirection, contentWidth, direction)
1501
- : resolveEdgeValue(child.style.margin, 0, style.flexDirection, contentWidth, direction) +
1502
- resolveEdgeValue(child.style.margin, 2, style.flexDirection, contentWidth, direction)
1503
- maxCrossSize = Math.max(maxCrossSize, childCross + childMargin)
1710
+ // For cross axis, compute shrink-wrap size
1711
+ // For multi-line (flex-wrap), sum line cross sizes + cross gaps
1712
+ // For single line, use max child cross size (existing behavior)
1713
+ let totalCrossSize = 0
1714
+ if (numLines > 1) {
1715
+ // Multi-line: sum line cross sizes + cross gaps between lines
1716
+ // Use saved arrays to avoid corruption by recursive layoutNode
1717
+ for (let i = 0; i < numLines; i++) {
1718
+ totalCrossSize += savedLineCrossSizes ? savedLineCrossSizes[i]! : _lineCrossSizes[i]!
1719
+ }
1720
+ totalCrossSize += crossGap * (numLines - 1)
1721
+ } else {
1722
+ // Single line: max child cross size
1723
+ // CSS spec: percentage margins resolve against containing block's WIDTH only
1724
+ // Use resolveEdgeValue to respect logical EDGE_START/END
1725
+ for (const child of node.children) {
1726
+ if (child.flex.relativeIndex < 0) continue
1727
+ const childCross = isRow ? child.layout.height : child.layout.width
1728
+ const childMargin = isRow
1729
+ ? resolveEdgeValue(child.style.margin, 1, style.flexDirection, contentWidth, direction) +
1730
+ resolveEdgeValue(child.style.margin, 3, style.flexDirection, contentWidth, direction)
1731
+ : resolveEdgeValue(child.style.margin, 0, style.flexDirection, contentWidth, direction) +
1732
+ resolveEdgeValue(child.style.margin, 2, style.flexDirection, contentWidth, direction)
1733
+ totalCrossSize = Math.max(totalCrossSize, childCross + childMargin)
1734
+ }
1504
1735
  }
1505
1736
  // Cross-axis shrink-wrap for auto-sized dimension
1506
1737
  // Only shrink-wrap when the available dimension is NaN (unconstrained)
1507
1738
  // When availableHeight/Width is defined, Yoga uses it for AUTO-sized root nodes
1739
+ // Skip if aspect ratio already determined this dimension (aspect ratio > shrink-wrap)
1508
1740
  if (
1509
1741
  isRow &&
1510
1742
  style.height.unit !== C.UNIT_POINT &&
1511
1743
  style.height.unit !== C.UNIT_PERCENT &&
1512
- Number.isNaN(availableHeight)
1744
+ Number.isNaN(availableHeight) &&
1745
+ !hasAR
1513
1746
  ) {
1514
- // Auto-height row: shrink-wrap to max child height
1515
- nodeHeight = maxCrossSize + innerTop + innerBottom
1747
+ // Auto-height row: shrink-wrap to total cross size (accounts for multi-line)
1748
+ nodeHeight = totalCrossSize + innerTop + innerBottom
1516
1749
  }
1517
1750
  if (
1518
1751
  !isRow &&
1519
1752
  style.width.unit !== C.UNIT_POINT &&
1520
1753
  style.width.unit !== C.UNIT_PERCENT &&
1521
- Number.isNaN(availableWidth)
1754
+ Number.isNaN(availableWidth) &&
1755
+ !hasAR
1522
1756
  ) {
1523
- // Auto-width column: shrink-wrap to max child width
1524
- nodeWidth = maxCrossSize + innerLeft + innerRight
1757
+ // Auto-width column: shrink-wrap to total cross size (accounts for multi-line)
1758
+ nodeWidth = totalCrossSize + innerLeft + innerRight
1525
1759
  }
1526
1760
  }
1527
1761
 
@@ -1539,6 +1773,156 @@ function layoutNode(
1539
1773
  nodeHeight = minInnerHeight
1540
1774
  }
1541
1775
 
1776
+ // -----------------------------------------------------------------------
1777
+ // PHASE 9b: Re-stretch children after shrink-wrap (Yoga compat)
1778
+ // -----------------------------------------------------------------------
1779
+ // When the parent's cross axis was auto (NaN during Phase 8), children with
1780
+ // stretch alignment were shrink-wrapped to content. Now that the cross size
1781
+ // is known from shrink-wrap, re-layout those children with the definite size.
1782
+ // This matches Yoga's two-pass approach for auto-sized containers.
1783
+ if (Number.isNaN(crossAxisSize) && relativeCount > 0) {
1784
+ const finalCross = isRow ? nodeHeight - innerTop - innerBottom : nodeWidth - innerLeft - innerRight
1785
+ if (!Number.isNaN(finalCross) && finalCross > 0) {
1786
+ for (const child of node.children) {
1787
+ if (child.flex.relativeIndex < 0) continue
1788
+ const cstyle = child.style
1789
+ // Determine alignment for this child
1790
+ let childAlign = style.alignItems
1791
+ if (cstyle.alignSelf !== C.ALIGN_AUTO) {
1792
+ childAlign = cstyle.alignSelf
1793
+ }
1794
+ // AR fallback: aspect-ratio prevents implicit stretch
1795
+ const cCrossDim = isRow ? cstyle.height : cstyle.width
1796
+ const cCrossIsAuto = cCrossDim.unit === C.UNIT_AUTO || cCrossDim.unit === C.UNIT_UNDEFINED
1797
+ if (
1798
+ childAlign === C.ALIGN_STRETCH &&
1799
+ cstyle.alignSelf === C.ALIGN_AUTO &&
1800
+ !Number.isNaN(cstyle.aspectRatio) &&
1801
+ cstyle.aspectRatio > 0 &&
1802
+ cCrossIsAuto
1803
+ ) {
1804
+ childAlign = C.ALIGN_FLEX_START
1805
+ }
1806
+ if (childAlign !== C.ALIGN_STRETCH) continue
1807
+ if (!cCrossIsAuto) continue
1808
+
1809
+ // Compute child's cross margin
1810
+ const cCrossMargin = isRow
1811
+ ? resolveEdgeValue(cstyle.margin, 1, style.flexDirection, contentWidth, direction) +
1812
+ resolveEdgeValue(cstyle.margin, 3, style.flexDirection, contentWidth, direction)
1813
+ : resolveEdgeValue(cstyle.margin, 0, style.flexDirection, contentWidth, direction) +
1814
+ resolveEdgeValue(cstyle.margin, 2, style.flexDirection, contentWidth, direction)
1815
+ const stretchedCross = finalCross - cCrossMargin
1816
+
1817
+ // Only re-layout if the cross size actually changed
1818
+ const currentCross = isRow ? child.layout.height : child.layout.width
1819
+ if (Math.round(stretchedCross) <= currentCross) continue
1820
+
1821
+ // Re-layout child with the definite cross size
1822
+ // Save position — layoutNode overwrites layout.left/top
1823
+ const savedLeft = child.layout.left
1824
+ const savedTop = child.layout.top
1825
+ const cMarginL = resolveEdgeValue(cstyle.margin, 0, style.flexDirection, contentWidth, direction)
1826
+ const cMarginT = resolveEdgeValue(cstyle.margin, 1, style.flexDirection, contentWidth, direction)
1827
+ const cAbsX = absX + innerLeft + savedLeft - cMarginL
1828
+ const cAbsY = absY + innerTop + savedTop - cMarginT
1829
+ const passW = isRow ? child.layout.width : stretchedCross
1830
+ const passH = isRow ? stretchedCross : child.layout.height
1831
+ layoutNode(child, passW, passH, savedLeft, savedTop, cAbsX, cAbsY, direction)
1832
+ // Restore position and override cross dimension to stretched size
1833
+ child.layout.left = savedLeft
1834
+ child.layout.top = savedTop
1835
+ if (isRow) {
1836
+ child.layout.height = Math.round(stretchedCross)
1837
+ } else {
1838
+ child.layout.width = Math.round(stretchedCross)
1839
+ }
1840
+ }
1841
+
1842
+ // -----------------------------------------------------------------------
1843
+ // PHASE 9c: Recompute cross-axis alignment after shrink-wrap
1844
+ // -----------------------------------------------------------------------
1845
+ // When the parent's cross axis was auto (NaN during Phase 8), alignment
1846
+ // offsets for CENTER, FLEX_END, and auto margins computed NaN because
1847
+ // availableCrossSpace = NaN - childSize - margin = NaN.
1848
+ // Now that cross size is known from shrink-wrap, recompute those offsets.
1849
+ if (Number.isNaN(crossAxisSize) && relativeCount > 0) {
1850
+ const finalCross9c = isRow ? nodeHeight - innerTop - innerBottom : nodeWidth - innerLeft - innerRight
1851
+ if (!Number.isNaN(finalCross9c) && finalCross9c > 0) {
1852
+ for (const child of node.children) {
1853
+ if (child.flex.relativeIndex < 0) continue
1854
+ const cstyle = child.style
1855
+ let childAlign = style.alignItems
1856
+ if (cstyle.alignSelf !== C.ALIGN_AUTO) {
1857
+ childAlign = cstyle.alignSelf
1858
+ }
1859
+ const cCrossDim = isRow ? cstyle.height : cstyle.width
1860
+ const cCrossIsAuto = cCrossDim.unit === C.UNIT_AUTO || cCrossDim.unit === C.UNIT_UNDEFINED
1861
+ if (
1862
+ childAlign === C.ALIGN_STRETCH &&
1863
+ cstyle.alignSelf === C.ALIGN_AUTO &&
1864
+ !Number.isNaN(cstyle.aspectRatio) &&
1865
+ cstyle.aspectRatio > 0 &&
1866
+ cCrossIsAuto
1867
+ ) {
1868
+ childAlign = C.ALIGN_FLEX_START
1869
+ }
1870
+
1871
+ const crossStartIdx = isRow ? 1 : 0
1872
+ const crossEndIdx = isRow ? 3 : 2
1873
+ const hasAutoStart = isEdgeAuto(cstyle.margin, crossStartIdx, style.flexDirection, direction)
1874
+ const hasAutoEnd = isEdgeAuto(cstyle.margin, crossEndIdx, style.flexDirection, direction)
1875
+ const needsAlignment =
1876
+ hasAutoStart || hasAutoEnd || childAlign === C.ALIGN_CENTER || childAlign === C.ALIGN_FLEX_END
1877
+ if (!needsAlignment) continue
1878
+
1879
+ const childCrossSize = isRow ? child.layout.height : child.layout.width
1880
+ const cCrossMargin = isRow
1881
+ ? resolveEdgeValue(cstyle.margin, 1, style.flexDirection, contentWidth, direction) +
1882
+ resolveEdgeValue(cstyle.margin, 3, style.flexDirection, contentWidth, direction)
1883
+ : resolveEdgeValue(cstyle.margin, 0, style.flexDirection, contentWidth, direction) +
1884
+ resolveEdgeValue(cstyle.margin, 2, style.flexDirection, contentWidth, direction)
1885
+ const availSpace = finalCross9c - childCrossSize - cCrossMargin
1886
+
1887
+ let crossOffset = 0
1888
+ if (hasAutoStart && hasAutoEnd) {
1889
+ crossOffset = Math.max(0, availSpace) / 2
1890
+ } else if (hasAutoStart) {
1891
+ crossOffset = Math.max(0, availSpace)
1892
+ } else if (hasAutoEnd) {
1893
+ crossOffset = 0
1894
+ } else {
1895
+ switch (childAlign) {
1896
+ case C.ALIGN_FLEX_END:
1897
+ crossOffset = availSpace
1898
+ break
1899
+ case C.ALIGN_CENTER:
1900
+ crossOffset = availSpace / 2
1901
+ break
1902
+ }
1903
+ }
1904
+
1905
+ if (isRow) {
1906
+ if (Number.isNaN(child.layout.top)) {
1907
+ const cMarginT = resolveEdgeValue(cstyle.margin, 1, style.flexDirection, contentWidth, direction)
1908
+ child.layout.top = Math.round(cMarginT + crossOffset)
1909
+ } else if (crossOffset !== 0) {
1910
+ child.layout.top += Math.round(crossOffset)
1911
+ }
1912
+ } else {
1913
+ if (Number.isNaN(child.layout.left)) {
1914
+ const cMarginL = resolveEdgeValue(cstyle.margin, 0, style.flexDirection, contentWidth, direction)
1915
+ child.layout.left = Math.round(cMarginL + crossOffset)
1916
+ } else if (crossOffset !== 0) {
1917
+ child.layout.left += Math.round(crossOffset)
1918
+ }
1919
+ }
1920
+ }
1921
+ }
1922
+ }
1923
+ }
1924
+ }
1925
+
1542
1926
  // =========================================================================
1543
1927
  // PHASE 10: Final Output - Set Node Layout
1544
1928
  // =========================================================================
@@ -1606,9 +1990,10 @@ function layoutNode(
1606
1990
  const hasAutoMarginBottom = isEdgeAuto(childStyle.margin, 3, style.flexDirection, direction)
1607
1991
 
1608
1992
  // Position offsets from setPosition(edge, value)
1609
- const leftPos = childStyle.position[0]
1993
+ // Resolve logical EDGE_START/EDGE_END to physical left/right based on direction
1994
+ const leftPos = resolvePositionEdge(childStyle.position, 0, direction)
1610
1995
  const topPos = childStyle.position[1]
1611
- const rightPos = childStyle.position[2]
1996
+ const rightPos = resolvePositionEdge(childStyle.position, 2, direction)
1612
1997
  const bottomPos = childStyle.position[3]
1613
1998
 
1614
1999
  const hasLeft = leftPos.unit !== C.UNIT_UNDEFINED
@@ -1616,10 +2001,11 @@ function layoutNode(
1616
2001
  const hasTop = topPos.unit !== C.UNIT_UNDEFINED
1617
2002
  const hasBottom = bottomPos.unit !== C.UNIT_UNDEFINED
1618
2003
 
1619
- const leftOffset = resolveValue(leftPos, nodeWidth)
1620
- const topOffset = resolveValue(topPos, nodeHeight)
1621
- const rightOffset = resolveValue(rightPos, nodeWidth)
1622
- const bottomOffset = resolveValue(bottomPos, nodeHeight)
2004
+ // Yoga resolves percentage position offsets against the content box dimensions
2005
+ const leftOffset = resolveValue(leftPos, absContentBoxW)
2006
+ const topOffset = resolveValue(topPos, absContentBoxH)
2007
+ const rightOffset = resolveValue(rightPos, absContentBoxW)
2008
+ const bottomOffset = resolveValue(bottomPos, absContentBoxH)
1623
2009
 
1624
2010
  // Calculate available size for absolute child using padding box
1625
2011
  const contentW = absPaddingBoxW
@@ -1692,28 +2078,45 @@ function layoutNode(
1692
2078
  const childHeight = child.layout.height
1693
2079
 
1694
2080
  // Apply alignment when no explicit position set
1695
- // For absolute children, align-items/justify-content apply when no position offsets
2081
+ // For absolute children, align-items applies on cross axis, justify-content on main axis
2082
+ // Row: X = main axis (justifyContent), Y = cross axis (alignItems)
2083
+ // Column: X = cross axis (alignItems), Y = main axis (justifyContent)
1696
2084
  if (!hasLeft && !hasRight) {
1697
- // No horizontal position - use align-items (for row) or justify-content (for column)
1698
- // Default column direction: cross-axis is horizontal, use alignItems
1699
- let alignment = style.alignItems
1700
- if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1701
- alignment = childStyle.alignSelf
1702
- }
1703
- const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1704
- switch (alignment) {
1705
- case C.ALIGN_CENTER:
1706
- childX = childMarginLeft + freeSpaceX / 2
1707
- break
1708
- case C.ALIGN_FLEX_END:
1709
- childX = childMarginLeft + freeSpaceX
1710
- break
1711
- case C.ALIGN_STRETCH:
1712
- // Stretch: already handled by setting width to fill
1713
- break
1714
- default: // FLEX_START
1715
- childX = childMarginLeft
1716
- break
2085
+ if (isRow) {
2086
+ // Row: X is main axis, use justifyContent
2087
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
2088
+ switch (style.justifyContent) {
2089
+ case C.JUSTIFY_CENTER:
2090
+ childX = childMarginLeft + freeSpaceX / 2
2091
+ break
2092
+ case C.JUSTIFY_FLEX_END:
2093
+ childX = childMarginLeft + freeSpaceX
2094
+ break
2095
+ default: // FLEX_START
2096
+ childX = childMarginLeft
2097
+ break
2098
+ }
2099
+ } else {
2100
+ // Column: X is cross axis, use alignItems/alignSelf
2101
+ let alignment = style.alignItems
2102
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
2103
+ alignment = childStyle.alignSelf
2104
+ }
2105
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
2106
+ switch (alignment) {
2107
+ case C.ALIGN_CENTER:
2108
+ childX = childMarginLeft + freeSpaceX / 2
2109
+ break
2110
+ case C.ALIGN_FLEX_END:
2111
+ childX = childMarginLeft + freeSpaceX
2112
+ break
2113
+ case C.ALIGN_STRETCH:
2114
+ // Stretch: already handled by setting width to fill
2115
+ break
2116
+ default: // FLEX_START
2117
+ childX = childMarginLeft
2118
+ break
2119
+ }
1717
2120
  }
1718
2121
  } else if (!hasLeft && hasRight) {
1719
2122
  // Position from right edge
@@ -1739,19 +2142,41 @@ function layoutNode(
1739
2142
  }
1740
2143
 
1741
2144
  if (!hasTop && !hasBottom) {
1742
- // No vertical position - use justify-content (for row) or align-items (for column)
1743
- // Default column direction: main-axis is vertical, use justifyContent
1744
- const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1745
- switch (style.justifyContent) {
1746
- case C.JUSTIFY_CENTER:
1747
- childY = childMarginTop + freeSpaceY / 2
1748
- break
1749
- case C.JUSTIFY_FLEX_END:
1750
- childY = childMarginTop + freeSpaceY
1751
- break
1752
- default: // FLEX_START
1753
- childY = childMarginTop
1754
- break
2145
+ if (isRow) {
2146
+ // Row: Y is cross axis, use alignItems/alignSelf
2147
+ let alignment = style.alignItems
2148
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
2149
+ alignment = childStyle.alignSelf
2150
+ }
2151
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
2152
+ switch (alignment) {
2153
+ case C.ALIGN_CENTER:
2154
+ childY = childMarginTop + freeSpaceY / 2
2155
+ break
2156
+ case C.ALIGN_FLEX_END:
2157
+ childY = childMarginTop + freeSpaceY
2158
+ break
2159
+ case C.ALIGN_STRETCH:
2160
+ // Stretch: already handled by setting height to fill
2161
+ break
2162
+ default: // FLEX_START
2163
+ childY = childMarginTop
2164
+ break
2165
+ }
2166
+ } else {
2167
+ // Column: Y is main axis, use justifyContent
2168
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
2169
+ switch (style.justifyContent) {
2170
+ case C.JUSTIFY_CENTER:
2171
+ childY = childMarginTop + freeSpaceY / 2
2172
+ break
2173
+ case C.JUSTIFY_FLEX_END:
2174
+ childY = childMarginTop + freeSpaceY
2175
+ break
2176
+ default: // FLEX_START
2177
+ childY = childMarginTop
2178
+ break
2179
+ }
1755
2180
  }
1756
2181
  } else if (!hasTop && hasBottom) {
1757
2182
  // Position from bottom edge
@@ -1786,6 +2211,8 @@ function layoutNode(
1786
2211
  flex.lastAvailH = availableHeight
1787
2212
  flex.lastOffsetX = offsetX
1788
2213
  flex.lastOffsetY = offsetY
2214
+ flex.lastAbsX = absX
2215
+ flex.lastAbsY = absY
1789
2216
  flex.lastDir = direction
1790
2217
  flex.layoutValid = true
1791
2218
  _t?.layoutExit(_tn, layout.width, layout.height)