craft-native 0.0.212 → 0.0.214

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.
@@ -353,6 +353,12 @@ object CraftNative {
353
353
  nodeData: FloatArray,
354
354
  treeMeta: FloatArray,
355
355
  ): FloatArray?
356
+ private external fun nativeLayoutTreeV4(
357
+ width: Float,
358
+ height: Float,
359
+ nodeData: FloatArray,
360
+ treeMeta: FloatArray,
361
+ ): FloatArray?
356
362
  private external fun nativeClipboardRead(activity: Activity): String?
357
363
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
358
364
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -635,6 +641,17 @@ object CraftNative {
635
641
  }
636
642
  }
637
643
 
644
+ /** Resolve a reverse-aware tree with shared grid-auto-rows support. */
645
+ fun layoutTreeV4(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
646
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
647
+ return try {
648
+ nativeLayoutTreeV4(width, height, nodeData, treeMeta)
649
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
650
+ } catch (e: UnsatisfiedLinkError) {
651
+ null
652
+ }
653
+ }
654
+
638
655
  /**
639
656
  * The clipboard's text, or null to use the Kotlin implementation.
640
657
  *
@@ -395,6 +395,7 @@ internal object CraftNativePrelude {
395
395
  if (typeof globalThis.window === 'undefined') globalThis.window = globalThis;
396
396
  (function() {
397
397
  var forward = null;
398
+ var listeners = [];
398
399
  var queued = [];
399
400
  var sync = { storage: {}, secure: {}, snapshots: {} };
400
401
  var preload = $STORES;
@@ -445,7 +446,11 @@ internal object CraftNativePrelude {
445
446
  }
446
447
  function send(value) { if (typeof value !== 'string') value = JSON.stringify(value); $post }
447
448
  function emit(message) {
448
- if (forward) forward(typeof message === 'string' ? message : JSON.stringify(message));
449
+ var raw = typeof message === 'string' ? message : JSON.stringify(message);
450
+ listeners.slice().forEach(function(callback) {
451
+ try { callback(raw); } catch (error) {}
452
+ });
453
+ if (forward) forward(raw);
449
454
  else queued.push(message);
450
455
  }
451
456
  function deliver(raw) {
@@ -515,6 +520,14 @@ internal object CraftNativePrelude {
515
520
  var pending = queued;
516
521
  queued = [];
517
522
  pending.forEach(emit);
523
+ },
524
+ addMessageListener: function(callback) {
525
+ if (typeof callback !== 'function') return function() {};
526
+ listeners.push(callback);
527
+ return function() {
528
+ var index = listeners.indexOf(callback);
529
+ if (index >= 0) listeners.splice(index, 1);
530
+ };
518
531
  }
519
532
  };
520
533
  $receiveSetup
@@ -563,6 +576,47 @@ internal object CraftNativePrelude {
563
576
  return value === undefined ? null : value;
564
577
  }
565
578
  });
579
+ // Older native bundles call the page-shaped `craft.dialog` API
580
+ // directly. Keep that API on the capability bridge so JavaScript
581
+ // Core/JavaScriptSandbox screens never fall back to a DOM sheet.
582
+ if (Array.isArray(__stxNativeBridge.capabilities) && __stxNativeBridge.capabilities.indexOf('dialog') >= 0 && !craft.dialog) {
583
+ var nextDialogRequest = 0;
584
+ function dialogRequest(method, args) {
585
+ return new Promise(function(resolve, reject) {
586
+ var id = 'craft-dialog-' + (++nextDialogRequest);
587
+ var remove = __stxNativeBridge.addMessageListener(function(raw) {
588
+ var message;
589
+ try { message = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (error) { return; }
590
+ var payload = message && message.payload;
591
+ if (!payload || payload.requestId !== id) return;
592
+ remove();
593
+ if (message.type === 'API_ERROR') {
594
+ var failure = new Error(payload.message || 'Native dialog failed');
595
+ failure.code = payload.code || 'NATIVE_CALL_FAILED';
596
+ reject(failure);
597
+ } else if (message.type === 'API_RESPONSE') {
598
+ resolve(payload.data === undefined ? null : payload.data);
599
+ }
600
+ });
601
+ __stxNativeBridge.postMessage(JSON.stringify({
602
+ type: 'API_REQUEST',
603
+ id: id,
604
+ payload: {
605
+ version: __stxNativeBridge.capabilityProtocolVersion || 1,
606
+ module: 'Dialog',
607
+ method: method,
608
+ args: args,
609
+ untimed: true,
610
+ },
611
+ }));
612
+ });
613
+ }
614
+ craft.dialog = {
615
+ alert: function(options) { return dialogRequest('alert', [options || {}]).then(function() {}); },
616
+ confirm: function(options) { return dialogRequest('confirm', [options || {}]).then(Boolean); },
617
+ actionSheet: function(options) { return dialogRequest('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); },
618
+ };
619
+ }
566
620
  })();
567
621
  """.trimIndent()
568
622
  }
@@ -1327,9 +1327,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1327
1327
  return result
1328
1328
  }
1329
1329
 
1330
- private fun defaultFlexShrink(view: View): Double = when {
1330
+ private fun defaultFlexShrink(view: View, horizontal: Boolean = orientation == HORIZONTAL): Double = when {
1331
1331
  view is ScrollView || view is HorizontalScrollView -> 1.0
1332
- view is TextView && view !is Button && view !is EditText && orientation == HORIZONTAL -> 1.0
1332
+ view is TextView && view !is Button && view !is EditText && horizontal -> 1.0
1333
1333
  else -> 0.0
1334
1334
  }
1335
1335
 
@@ -1372,6 +1372,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1372
1372
  val margins: IntArray,
1373
1373
  val grid: Boolean,
1374
1374
  val gridColumns: Int,
1375
+ val gridAutoRows: CraftNativeLength?,
1375
1376
  val reverse: Boolean,
1376
1377
  val wrapReverse: Boolean,
1377
1378
  val children: List<SharedTreeNode>,
@@ -1383,14 +1384,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1383
1384
  val frames: FloatArray,
1384
1385
  )
1385
1386
 
1386
- /**
1387
- * Flatten a flex or equal-track grid subtree for the shared ABI. The local
1388
- * layout stays authoritative for grid-auto-rows,
1389
- * absolute/modal children, and scroll hosts; those cases cannot be
1390
- * represented without changing their native measurement contract.
1391
- */
1387
+ /** Flatten a flex or equal-track grid subtree for the shared ABI. */
1392
1388
  private fun sharedTreeChildren(width: Int, height: Int): List<Entry>? {
1393
- if (gridAutoRows != null) return null
1394
1389
  if (grid) {
1395
1390
  val layout = gridLayout(width, height)
1396
1391
  if (layout.absolute.isNotEmpty()) return null
@@ -1428,6 +1423,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1428
1423
  margins = intArrayOf(lp?.leftMargin ?: 0, lp?.rightMargin ?: 0, lp?.topMargin ?: 0, lp?.bottomMargin ?: 0),
1429
1424
  grid = nested?.grid ?: false,
1430
1425
  gridColumns = nested?.gridColumns ?: 1,
1426
+ gridAutoRows = nested?.gridAutoRows,
1431
1427
  reverse = nested?.reverse ?: false,
1432
1428
  wrapReverse = nested?.wrapReverse ?: false,
1433
1429
  children = children.map { child -> sharedTreeNode(child) ?: return null },
@@ -1435,7 +1431,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1435
1431
  }
1436
1432
 
1437
1433
  private fun sharedTreeFrames(entries: List<Entry>, width: Int, height: Int): SharedTreeResult? {
1438
- if (entries.isEmpty() || width < 0 || height < 0 || gridAutoRows != null) return null
1434
+ if (entries.isEmpty() || width < 0 || height < 0) return null
1439
1435
  val children = entries.map { sharedTreeNode(it) ?: return null }
1440
1436
  val root = SharedTreeNode(
1441
1437
  view = null,
@@ -1455,6 +1451,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1455
1451
  margins = intArrayOf(0, 0, 0, 0),
1456
1452
  grid = grid,
1457
1453
  gridColumns = gridColumns,
1454
+ gridAutoRows = gridAutoRows,
1458
1455
  reverse = reverse,
1459
1456
  wrapReverse = wrapReverse,
1460
1457
  children = children,
@@ -1470,14 +1467,19 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1470
1467
  append(root, -1)
1471
1468
  val reverseAware = nodes.any { it.reverse || it.wrapReverse }
1472
1469
  val wrapReverseAware = nodes.any { it.wrapReverse }
1473
- val fields = if (reverseAware) 33 else 32
1470
+ val autoRowsAware = nodes.any { it.gridAutoRows != null }
1471
+ val fields = when {
1472
+ autoRowsAware -> 34
1473
+ reverseAware -> 33
1474
+ else -> 32
1475
+ }
1474
1476
  val nodeData = FloatArray(nodes.size * fields) { Float.NaN }
1475
1477
  val treeMeta = FloatArray(nodes.size * 2)
1476
1478
  fun enumBits(value: Int): Float = Float.fromBits(value)
1477
1479
  fun put(base: Int, index: Int, value: Float) { nodeData[base + index] = value }
1478
1480
  fun putEnum(base: Int, index: Int, value: Int) { put(base, index, enumBits(value)) }
1479
1481
  var cursor = 0
1480
- fun write(node: SharedTreeNode, parentWidth: Int, parentHeight: Int, rootNode: Boolean) {
1482
+ fun write(node: SharedTreeNode, parentWidth: Int, parentHeight: Int, rootNode: Boolean, parentDirection: Int) {
1481
1483
  val index = cursor++
1482
1484
  val base = index * fields
1483
1485
  val measuredWidth = if (rootNode) width else node.style.width?.resolve(parentWidth) ?: node.intrinsicWidth.roundToInt()
@@ -1494,7 +1496,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1494
1496
  put(base, 6, node.style.aspectRatio?.toFloat() ?: Float.NaN)
1495
1497
  put(base, 7, sharedLength(node.style.flexBasis, mainSize))
1496
1498
  put(base, 8, if (rootNode) 0f else node.style.flexGrow.toFloat().coerceAtLeast(0f))
1497
- put(base, 9, if (rootNode) 0f else (node.style.flexShrink ?: 0.0).toFloat().coerceAtLeast(0f))
1499
+ put(base, 9, if (rootNode) 0f else (node.style.flexShrink ?: node.view?.let { defaultFlexShrink(it, parentDirection == 0) } ?: 0.0).toFloat().coerceAtLeast(0f))
1498
1500
  put(base, 10, sharedLength(node.style.minWidth, contentWidth))
1499
1501
  put(base, 11, sharedLength(node.style.maxWidth, contentWidth))
1500
1502
  put(base, 12, sharedLength(node.style.minHeight, contentHeight))
@@ -1517,14 +1519,17 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1517
1519
  put(base, 29, node.gridColumns.toFloat())
1518
1520
  put(base, 30, node.rowGap)
1519
1521
  put(base, 31, node.columnGap)
1520
- if (reverseAware) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
1522
+ if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
1523
+ if (autoRowsAware) put(base, 33, sharedLength(node.gridAutoRows, contentHeight))
1521
1524
  val firstChild = cursor
1522
- node.children.forEach { child -> write(child, contentWidth, contentHeight, false) }
1525
+ node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) }
1523
1526
  treeMeta[index * 2] = firstChild.toFloat()
1524
1527
  treeMeta[index * 2 + 1] = (cursor - firstChild).toFloat()
1525
1528
  }
1526
- write(root, width, height, true)
1527
- val frames = (if (wrapReverseAware) {
1529
+ write(root, width, height, true, root.direction)
1530
+ val frames = (if (autoRowsAware) {
1531
+ CraftNative.layoutTreeV4(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1532
+ } else if (wrapReverseAware) {
1528
1533
  CraftNative.layoutTreeV3(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1529
1534
  } else if (reverseAware) {
1530
1535
  CraftNative.layoutTreeV2(width.toFloat(), height.toFloat(), nodeData, treeMeta)
@@ -1537,9 +1542,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1537
1542
  /**
1538
1543
  * Use the host-neutral tree ABI when platform measurements are complete:
1539
1544
  * definite direct-child containers, including wrapped flows, equal-track
1540
- * grids, and independent row and column gaps. Grid
1541
- * auto-rows, absolute children, missing runtimes, and malformed results
1542
- * deliberately fall through to the established Kotlin layout below.
1545
+ * grids, independent row and column gaps, and grid-auto-rows. Absolute
1546
+ * children, missing runtimes, and malformed results deliberately fall
1547
+ * through to the established Kotlin layout below.
1543
1548
  */
1544
1549
  private fun sharedDirectFrames(entries: List<Entry>, width: Int, height: Int): FloatArray? {
1545
1550
  if (entries.isEmpty() || width < 0 || height < 0 || grid || reverse || wrapReverse || gridAutoRows != null) return null
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.212";
3427
+ var version = "0.0.214";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.212";
312
+ var version = "0.0.214";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.212";
60
+ var version = "0.0.214";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -308,7 +308,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
308
308
 
309
309
  func setContentContainerStyle(_ raw: [String: Any]?) {
310
310
  func nonNegative(_ value: Any?) -> CGFloat? {
311
- guard let number = value as? NSNumber else { return nil }
311
+ guard !(value is Bool), let number = value as? NSNumber else { return nil }
312
312
  let points = CGFloat(truncating: number)
313
313
  guard points.isFinite, points >= 0 else { return nil }
314
314
  return points
@@ -16,7 +16,9 @@ private let craftNativeCapabilityTimeoutMilliseconds = 30_000
16
16
  /// which has its own: the keyboard-avoiding view and SVG text read theirs
17
17
  /// with it, and without it the template did not compile.
18
18
  private func number(_ value: Any?) -> CGFloat? {
19
- guard let number = value as? NSNumber else { return nil }
19
+ // JavaScript booleans bridge to NSNumber on Apple platforms; they are
20
+ // invalid numeric styles and must stay unset like Android's Number cast.
21
+ guard !(value is Bool), let number = value as? NSNumber else { return nil }
20
22
  let value = CGFloat(truncating: number)
21
23
  return value.isFinite ? value : nil
22
24
  }
@@ -30,12 +32,12 @@ private func craftNativeFontWeight(_ value: Any?) -> String? {
30
32
 
31
33
  private func craftNativePickerValue(_ value: Any?) -> String? {
32
34
  if let value = value as? String, !value.isEmpty { return value }
33
- if let value = value as? NSNumber, value.doubleValue.isFinite { return String(value.doubleValue) }
35
+ if !(value is Bool), let value = value as? NSNumber, value.doubleValue.isFinite { return String(value.doubleValue) }
34
36
  return nil
35
37
  }
36
38
 
37
39
  private func craftNativeGridColumnCount(_ value: Any?) -> Int {
38
- if let number = value as? NSNumber { return max(1, number.intValue) }
40
+ if !(value is Bool), let number = value as? NSNumber { return max(1, number.intValue) }
39
41
  guard let text = value as? String else { return 1 }
40
42
  let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
41
43
  if trimmed.hasPrefix("repeat(") {
@@ -190,6 +192,49 @@ private enum CraftNativeTreeLayoutBridge {
190
192
  var childCount: UInt32
191
193
  }
192
194
 
195
+ private struct StyleInputV4 {
196
+ var direction: UInt32 = 0
197
+ var width: Float = .nan
198
+ var height: Float = .nan
199
+ var intrinsicWidth: Float = .nan
200
+ var intrinsicHeight: Float = .nan
201
+ var baseline: Float = .nan
202
+ var aspectRatio: Float = .nan
203
+ var flexBasis: Float = .nan
204
+ var flexGrow: Float = 0
205
+ var flexShrink: Float = 0
206
+ var minWidth: Float = .nan
207
+ var maxWidth: Float = .nan
208
+ var minHeight: Float = .nan
209
+ var maxHeight: Float = .nan
210
+ var gap: Float = 0
211
+ var justifyContent: UInt32 = 0
212
+ var alignItems: UInt32 = 3
213
+ var alignSelf: UInt32 = 0
214
+ var flexWrap: UInt32 = 0
215
+ var alignContent: UInt32 = 6
216
+ var marginTop: Float = 0
217
+ var marginRight: Float = 0
218
+ var marginBottom: Float = 0
219
+ var marginLeft: Float = 0
220
+ var paddingTop: Float = 0
221
+ var paddingRight: Float = 0
222
+ var paddingBottom: Float = 0
223
+ var paddingLeft: Float = 0
224
+ var grid: UInt32 = 0
225
+ var gridColumns: UInt32 = 1
226
+ var rowGap: Float = 0
227
+ var columnGap: Float = 0
228
+ var reverse: UInt32 = 0
229
+ var gridAutoRows: Float = .nan
230
+ }
231
+
232
+ private struct NodeInputV4 {
233
+ var style: StyleInputV4
234
+ var firstChild: UInt32
235
+ var childCount: UInt32
236
+ }
237
+
193
238
  fileprivate struct Frame {
194
239
  var x: Float
195
240
  var y: Float
@@ -213,6 +258,7 @@ private enum CraftNativeTreeLayoutBridge {
213
258
  let padding: UIEdgeInsets
214
259
  let grid: Bool
215
260
  let gridColumns: Int
261
+ let gridAutoRows: CraftNativeLength?
216
262
  let reverse: Bool
217
263
  let wrapReverse: Bool
218
264
  let children: [TreeNode]
@@ -247,6 +293,11 @@ private enum CraftNativeTreeLayoutBridge {
247
293
  return unsafeBitCast(symbol, to: FunctionV2.self)
248
294
  }()
249
295
 
296
+ private static let functionV4: FunctionV2? = {
297
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v4") else { return nil }
298
+ return unsafeBitCast(symbol, to: FunctionV2.self)
299
+ }()
300
+
250
301
  private static func finite(_ value: CGFloat?) -> Float {
251
302
  guard let value, value.isFinite else { return .nan }
252
303
  return Float(value)
@@ -359,6 +410,9 @@ private enum CraftNativeTreeLayoutBridge {
359
410
 
360
411
  fileprivate static func treeFrames(root: TreeNode, width: CGFloat, height: CGFloat) -> TreeResult? {
361
412
  guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
413
+ if containsGridAutoRows(root) {
414
+ return treeFramesV4(root: root, width: width, height: height, function: functionV4)
415
+ }
362
416
  if containsWrapReverse(root) {
363
417
  return treeFramesV2(root: root, width: width, height: height, function: functionV3)
364
418
  }
@@ -375,7 +429,7 @@ private enum CraftNativeTreeLayoutBridge {
375
429
  }
376
430
  append(root, parent: -1)
377
431
 
378
- func put(_ input: inout StyleInput, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
432
+ func put(_ input: inout StyleInput, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
379
433
  let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
380
434
  let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
381
435
  let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
@@ -388,7 +442,7 @@ private enum CraftNativeTreeLayoutBridge {
388
442
  input.aspectRatio = finite(node.style.aspectRatio)
389
443
  input.flexBasis = length(node.style.flexBasis, base: mainSize)
390
444
  input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
391
- let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
445
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && parentDirection == .horizontal) ? 1 : 0
392
446
  input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
393
447
  input.minWidth = length(node.style.minWidth, base: contentWidth)
394
448
  input.maxWidth = length(node.style.maxWidth, base: contentWidth)
@@ -417,18 +471,18 @@ private enum CraftNativeTreeLayoutBridge {
417
471
  var styles = [StyleInput](repeating: StyleInput(), count: nodes.count)
418
472
  var metadata = [Float](repeating: 0, count: nodes.count * 2)
419
473
  var cursor = 0
420
- func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
474
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
421
475
  let index = cursor
422
476
  cursor += 1
423
- put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
477
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode, parentDirection: parentDirection)
424
478
  let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
425
479
  let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
426
480
  let firstChild = cursor
427
- node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
481
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false, parentDirection: node.direction) }
428
482
  metadata[index * 2] = Float(firstChild)
429
483
  metadata[index * 2 + 1] = Float(cursor - firstChild)
430
484
  }
431
- write(root, parentWidth: width, parentHeight: height, rootNode: true)
485
+ write(root, parentWidth: width, parentHeight: height, rootNode: true, parentDirection: root.direction)
432
486
  let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
433
487
  NodeInput(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
434
488
  }
@@ -460,6 +514,10 @@ private enum CraftNativeTreeLayoutBridge {
460
514
  node.wrapReverse || node.children.contains(where: containsWrapReverse)
461
515
  }
462
516
 
517
+ private static func containsGridAutoRows(_ node: TreeNode) -> Bool {
518
+ node.gridAutoRows != nil || node.children.contains(where: containsGridAutoRows)
519
+ }
520
+
463
521
  private static func treeFramesV2(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
464
522
  guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
465
523
  var nodes: [TreeNode] = []
@@ -472,7 +530,7 @@ private enum CraftNativeTreeLayoutBridge {
472
530
  }
473
531
  append(root, parent: -1)
474
532
 
475
- func put(_ input: inout StyleInputV2, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
533
+ func put(_ input: inout StyleInputV2, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
476
534
  let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
477
535
  let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
478
536
  let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
@@ -485,7 +543,7 @@ private enum CraftNativeTreeLayoutBridge {
485
543
  input.aspectRatio = finite(node.style.aspectRatio)
486
544
  input.flexBasis = length(node.style.flexBasis, base: mainSize)
487
545
  input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
488
- let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
546
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && parentDirection == .horizontal) ? 1 : 0
489
547
  input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
490
548
  input.minWidth = length(node.style.minWidth, base: contentWidth)
491
549
  input.maxWidth = length(node.style.maxWidth, base: contentWidth)
@@ -515,18 +573,18 @@ private enum CraftNativeTreeLayoutBridge {
515
573
  var styles = [StyleInputV2](repeating: StyleInputV2(), count: nodes.count)
516
574
  var metadata = [Float](repeating: 0, count: nodes.count * 2)
517
575
  var cursor = 0
518
- func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
576
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
519
577
  let index = cursor
520
578
  cursor += 1
521
- put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
579
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode, parentDirection: parentDirection)
522
580
  let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
523
581
  let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
524
582
  let firstChild = cursor
525
- node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
583
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false, parentDirection: node.direction) }
526
584
  metadata[index * 2] = Float(firstChild)
527
585
  metadata[index * 2 + 1] = Float(cursor - firstChild)
528
586
  }
529
- write(root, parentWidth: width, parentHeight: height, rootNode: true)
587
+ write(root, parentWidth: width, parentHeight: height, rootNode: true, parentDirection: root.direction)
530
588
  let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
531
589
  NodeInputV2(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
532
590
  }
@@ -549,6 +607,100 @@ private enum CraftNativeTreeLayoutBridge {
549
607
  }) else { return nil }
550
608
  return TreeResult(nodes: nodes, parents: parents, frames: output)
551
609
  }
610
+
611
+ /// v4 is the reverse-aware tree payload with one resolved grid-auto-rows
612
+ /// value appended. Keeping this as a separate struct preserves the v1–v3
613
+ /// strides for runtimes that predate the shared auto-row contract.
614
+ private static func treeFramesV4(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
615
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
616
+ var nodes: [TreeNode] = []
617
+ var parents: [Int] = []
618
+ func append(_ node: TreeNode, parent: Int) {
619
+ let index = nodes.count
620
+ nodes.append(node)
621
+ parents.append(parent)
622
+ node.children.forEach { append($0, parent: index) }
623
+ }
624
+ append(root, parent: -1)
625
+
626
+ func put(_ input: inout StyleInputV4, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
627
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
628
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
629
+ let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
630
+ input.direction = node.direction == .horizontal ? 0 : 1
631
+ input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
632
+ input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
633
+ input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
634
+ input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
635
+ input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
636
+ input.aspectRatio = finite(node.style.aspectRatio)
637
+ input.flexBasis = length(node.style.flexBasis, base: mainSize)
638
+ input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
639
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && parentDirection == .horizontal) ? 1 : 0
640
+ input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
641
+ input.minWidth = length(node.style.minWidth, base: contentWidth)
642
+ input.maxWidth = length(node.style.maxWidth, base: contentWidth)
643
+ input.minHeight = length(node.style.minHeight, base: contentHeight)
644
+ input.maxHeight = length(node.style.maxHeight, base: contentHeight)
645
+ input.gap = Float(max(0, node.gap))
646
+ input.justifyContent = justify(node.justifyContent)
647
+ input.alignItems = alignment(node.alignItems)
648
+ input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
649
+ input.flexWrap = node.wrap ? 1 : 0
650
+ input.alignContent = craftNativeAlignContentCode(node.alignContent)
651
+ input.marginTop = Float(max(0, node.style.marginTop))
652
+ input.marginRight = Float(max(0, node.style.marginRight))
653
+ input.marginBottom = Float(max(0, node.style.marginBottom))
654
+ input.marginLeft = Float(max(0, node.style.marginLeft))
655
+ input.paddingTop = Float(max(0, node.padding.top))
656
+ input.paddingRight = Float(max(0, node.padding.right))
657
+ input.paddingBottom = Float(max(0, node.padding.bottom))
658
+ input.paddingLeft = Float(max(0, node.padding.left))
659
+ input.grid = node.grid ? 1 : 0
660
+ input.gridColumns = UInt32(max(1, node.gridColumns))
661
+ input.rowGap = Float(max(0, node.rowGap))
662
+ input.columnGap = Float(max(0, node.columnGap))
663
+ input.reverse = (node.reverse ? 1 : 0) | (node.wrapReverse ? 2 : 0)
664
+ input.gridAutoRows = node.gridAutoRows.map { length($0, base: contentHeight) } ?? .nan
665
+ }
666
+
667
+ var styles = [StyleInputV4](repeating: StyleInputV4(), count: nodes.count)
668
+ var metadata = [Float](repeating: 0, count: nodes.count * 2)
669
+ var cursor = 0
670
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
671
+ let index = cursor
672
+ cursor += 1
673
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode, parentDirection: parentDirection)
674
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
675
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
676
+ let firstChild = cursor
677
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false, parentDirection: node.direction) }
678
+ metadata[index * 2] = Float(firstChild)
679
+ metadata[index * 2 + 1] = Float(cursor - firstChild)
680
+ }
681
+ write(root, parentWidth: width, parentHeight: height, rootNode: true, parentDirection: root.direction)
682
+ let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
683
+ NodeInputV4(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
684
+ }
685
+
686
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
687
+ let frameCount = output.count
688
+ let status = inputs.withUnsafeBufferPointer { inputBuffer in
689
+ metadata.withUnsafeBufferPointer { metadataBuffer in
690
+ output.withUnsafeMutableBufferPointer { outputBuffer in
691
+ function(
692
+ Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
693
+ UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
694
+ )
695
+ }
696
+ }
697
+ }
698
+ guard status == 0, output.allSatisfy({ frame in
699
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
700
+ frame.width >= 0 && frame.height >= 0
701
+ }) else { return nil }
702
+ return TreeResult(nodes: nodes, parents: parents, frames: output)
703
+ }
552
704
  }
553
705
 
554
706
  private enum CraftNativeFlexLineBridge {
@@ -1047,7 +1199,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1047
1199
  }
1048
1200
  } else if type == "mappolyline" || type == "polyline" {
1049
1201
  guard points.count > 1 else { continue }
1050
- let authoredWidth = (childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3
1202
+ let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 3
1051
1203
  let width = CGFloat(authoredWidth)
1052
1204
  guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
1053
1205
  nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
@@ -1182,8 +1334,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1182
1334
  }
1183
1335
 
1184
1336
  private static func coordinates(_ value: Any?) -> [CLLocationCoordinate2D] {
1185
- let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
1186
- ?? (value as? [NSNumber])?.map(\.doubleValue)
1337
+ let numbers = (value as? [Any])?.compactMap(Self.numeric)
1338
+ ?? (value as? [NSNumber])?.compactMap(Self.numeric)
1187
1339
  ?? []
1188
1340
  return stride(from: 0, to: numbers.count - 1, by: 2).compactMap {
1189
1341
  let latitude = numbers[$0]
@@ -1224,17 +1376,22 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1224
1376
  guard let value = value as? [String: Any],
1225
1377
  let center = value["center"] as? [String: Any],
1226
1378
  let span = value["span"] as? [String: Any],
1227
- let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
1228
- let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
1229
- let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
1230
- let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue,
1231
- latitude.isFinite, longitude.isFinite, latitudeDelta.isFinite, longitudeDelta.isFinite,
1379
+ let latitude = numeric(center["latitude"]),
1380
+ let longitude = numeric(center["longitude"]),
1381
+ let latitudeDelta = numeric(span["latitudeDelta"]) ?? numeric(span["latitude_delta"]),
1382
+ let longitudeDelta = numeric(span["longitudeDelta"]) ?? numeric(span["longitude_delta"]),
1232
1383
  (-90...90).contains(latitude), (-180...180).contains(longitude),
1233
1384
  latitudeDelta > 0, latitudeDelta <= 180, longitudeDelta > 0, longitudeDelta <= 360,
1234
1385
  latitude - latitudeDelta / 2 >= -90, latitude + latitudeDelta / 2 <= 90 else { return nil }
1235
1386
  return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
1236
1387
  }
1237
1388
 
1389
+ private static func numeric(_ value: Any?) -> Double? {
1390
+ guard !(value is Bool), let number = value as? NSNumber else { return nil }
1391
+ let value = number.doubleValue
1392
+ return value.isFinite ? value : nil
1393
+ }
1394
+
1238
1395
  private func nonEmptyHandler(_ handler: String?) -> String? {
1239
1396
  guard let handler, !handler.isEmpty else { return nil }
1240
1397
  return handler
@@ -1299,7 +1456,9 @@ private enum CraftNativeLength: Equatable {
1299
1456
  case percent(CGFloat)
1300
1457
 
1301
1458
  init?(_ value: Any?) {
1302
- if let number = value as? NSNumber {
1459
+ // JavaScript booleans can bridge to NSNumber on Apple platforms;
1460
+ // they are invalid CSS lengths and must stay unset like Android.
1461
+ if !(value is Bool), let number = value as? NSNumber {
1303
1462
  let points = CGFloat(truncating: number)
1304
1463
  guard points.isFinite else { return nil }
1305
1464
  self = .points(points)
@@ -1357,7 +1516,7 @@ private struct CraftNativeLayoutStyle {
1357
1516
 
1358
1517
  init(_ raw: [String: Any]) {
1359
1518
  func number(_ value: Any?) -> CGFloat? {
1360
- guard let number = value as? NSNumber else { return nil }
1519
+ guard !(value is Bool), let number = value as? NSNumber else { return nil }
1361
1520
  let value = CGFloat(truncating: number)
1362
1521
  return value.isFinite ? value : nil
1363
1522
  }
@@ -1977,11 +2136,10 @@ private class CraftNativeFlowView: UIStackView {
1977
2136
  }
1978
2137
 
1979
2138
  /// Builds the eligible portion of this container for the host-neutral
1980
- /// tree ABI. The local algorithm remains authoritative for
1981
- /// grid-auto-rows, absolute children, and any subtree whose measurement
1982
- /// cannot be represented by the shared contract.
2139
+ /// tree ABI. The local algorithm remains authoritative for absolute
2140
+ /// children and any subtree whose measurement cannot be represented by
2141
+ /// the shared contract.
1983
2142
  fileprivate func sharedTreeNode(parentWidth: CGFloat, parentHeight: CGFloat) -> CraftNativeTreeLayoutBridge.TreeNode? {
1984
- guard gridAutoRows == nil else { return nil }
1985
2143
  let children = visibleChildren
1986
2144
  let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
1987
2145
  guard flow.count == children.count else { return nil }
@@ -2025,6 +2183,7 @@ private class CraftNativeFlowView: UIStackView {
2025
2183
  padding: nested?.effectivePadding ?? .zero,
2026
2184
  grid: nested?.grid ?? false,
2027
2185
  gridColumns: nested?.gridColumns ?? 1,
2186
+ gridAutoRows: nested?.gridAutoRows,
2028
2187
  reverse: nested?.reverse ?? false,
2029
2188
  wrapReverse: nested?.wrapReverse ?? false,
2030
2189
  children: nestedNode?.children ?? []
@@ -2047,6 +2206,7 @@ private class CraftNativeFlowView: UIStackView {
2047
2206
  padding: effectivePadding,
2048
2207
  grid: grid,
2049
2208
  gridColumns: gridColumns,
2209
+ gridAutoRows: gridAutoRows,
2050
2210
  reverse: reverse,
2051
2211
  wrapReverse: wrapReverse,
2052
2212
  children: nodes.compactMap { $0 }
@@ -2054,7 +2214,7 @@ private class CraftNativeFlowView: UIStackView {
2054
2214
  }
2055
2215
 
2056
2216
  private func sharedTreeFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> CraftNativeTreeLayoutBridge.TreeResult? {
2057
- guard let width, let height, gridAutoRows == nil,
2217
+ guard let width, let height,
2058
2218
  width.isFinite, height.isFinite, width >= 0, height >= 0 else { return nil }
2059
2219
  let nodes = children.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
2060
2220
  let style = child.craftLayoutStyle
@@ -2078,6 +2238,7 @@ private class CraftNativeFlowView: UIStackView {
2078
2238
  padding: nested?.effectivePadding ?? .zero,
2079
2239
  grid: nested?.grid ?? false,
2080
2240
  gridColumns: nested?.gridColumns ?? 1,
2241
+ gridAutoRows: nested?.gridAutoRows,
2081
2242
  reverse: nested?.reverse ?? false,
2082
2243
  wrapReverse: nested?.wrapReverse ?? false,
2083
2244
  children: nestedNode?.children ?? []
@@ -2100,6 +2261,7 @@ private class CraftNativeFlowView: UIStackView {
2100
2261
  padding: effectivePadding,
2101
2262
  grid: grid,
2102
2263
  gridColumns: gridColumns,
2264
+ gridAutoRows: gridAutoRows,
2103
2265
  reverse: reverse,
2104
2266
  wrapReverse: wrapReverse,
2105
2267
  children: nodes.compactMap { $0 }
@@ -3072,7 +3234,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
3072
3234
  /// these indices stay at the top of the visible area once scrolled to,
3073
3235
  /// each pushed up by the next. Vertical scroll views only.
3074
3236
  func setStickyHeaderIndices(_ value: Any?) {
3075
- let next = Array(Set(((value as? [Any]) ?? []).compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 >= 0 })).sorted()
3237
+ let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map { Int($0) } }.filter { $0 >= 0 })).sorted()
3076
3238
  guard next != stickyIndices else { return }
3077
3239
  stickyIndices = next
3078
3240
  setNeedsLayout()
@@ -3302,7 +3464,7 @@ private final class CraftNativeSvgView: UIView {
3302
3464
  var viewBox: CGRect? {
3303
3465
  let props = drawing["props"] as? [String: Any] ?? [:]
3304
3466
  guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
3305
- let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
3467
+ let n = box.compactMap(number)
3306
3468
  guard n.count == 4, n.allSatisfy(\.isFinite), n[2] > 0, n[3] > 0 else { return nil }
3307
3469
  return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
3308
3470
  }
@@ -3339,9 +3501,7 @@ private final class CraftNativeSvgView: UIView {
3339
3501
  let style = node["style"] as? [String: Any] ?? [:]
3340
3502
  func value(_ key: String) -> Any? { style[key] ?? props[key] }
3341
3503
  func finite(_ raw: Any?) -> CGFloat? {
3342
- guard let number = raw as? NSNumber else { return nil }
3343
- let value = CGFloat(truncating: number)
3344
- return value.isFinite ? value : nil
3504
+ return number(raw)
3345
3505
  }
3346
3506
  func string(_ raw: Any?) -> String? {
3347
3507
  if let value = raw as? String { return value }
@@ -3500,7 +3660,7 @@ private final class CraftNativeSvgView: UIView {
3500
3660
 
3501
3661
  static func path(_ value: Any?) -> UIBezierPath? {
3502
3662
  guard let list = value as? [Any] else { return nil }
3503
- let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
3663
+ let n = list.compactMap(number)
3504
3664
  guard !n.isEmpty, n.count == list.count, n.allSatisfy(\.isFinite) else { return nil }
3505
3665
  let path = UIBezierPath()
3506
3666
  var i = 0
@@ -4330,6 +4490,74 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4330
4490
  jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
4331
4491
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
4332
4492
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
4493
+ // Older native bundles call the page-shaped `craft.dialog` API
4494
+ // directly. Keep that API backed by the same capability protocol as
4495
+ // current stx bundles, rather than letting their DOM fallback draw a
4496
+ // fake sheet in a JavaScriptCore screen.
4497
+ jsContext.evaluateScript("""
4498
+ (function() {
4499
+ var bridge = globalThis.__stxNativeBridge;
4500
+ if (!bridge || typeof bridge.postMessage !== 'function') return;
4501
+ var listeners = [];
4502
+ var runtimeCallback = null;
4503
+ bridge.onMessage = function(callback) {
4504
+ runtimeCallback = typeof callback === 'function' ? callback : null;
4505
+ };
4506
+ bridge.addMessageListener = function(callback) {
4507
+ if (typeof callback !== 'function') return function() {};
4508
+ listeners.push(callback);
4509
+ return function() {
4510
+ var index = listeners.indexOf(callback);
4511
+ if (index >= 0) listeners.splice(index, 1);
4512
+ };
4513
+ };
4514
+ globalThis.__stxNativeCallback = function(raw) {
4515
+ listeners.slice().forEach(function(callback) {
4516
+ try { callback(raw); } catch (error) {}
4517
+ });
4518
+ if (runtimeCallback) runtimeCallback(raw);
4519
+ };
4520
+ var nextRequest = 0;
4521
+ function request(method, args) {
4522
+ return new Promise(function(resolve, reject) {
4523
+ var id = 'craft-dialog-' + (++nextRequest);
4524
+ var remove = bridge.addMessageListener(function(raw) {
4525
+ var message;
4526
+ try { message = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (error) { return; }
4527
+ var payload = message && message.payload;
4528
+ if (!payload || payload.requestId !== id) return;
4529
+ remove();
4530
+ if (message.type === 'API_ERROR') {
4531
+ var failure = new Error(payload.message || 'Native dialog failed');
4532
+ failure.code = payload.code || 'NATIVE_CALL_FAILED';
4533
+ reject(failure);
4534
+ } else if (message.type === 'API_RESPONSE') {
4535
+ resolve(payload.data === undefined ? null : payload.data);
4536
+ }
4537
+ });
4538
+ bridge.postMessage(JSON.stringify({
4539
+ type: 'API_REQUEST',
4540
+ id: id,
4541
+ payload: {
4542
+ version: bridge.capabilityProtocolVersion || 1,
4543
+ module: 'Dialog',
4544
+ method: method,
4545
+ args: args,
4546
+ untimed: true
4547
+ }
4548
+ }));
4549
+ });
4550
+ }
4551
+ var craft = globalThis.craft = globalThis.craft || {};
4552
+ if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0 && !craft.dialog) {
4553
+ craft.dialog = {
4554
+ alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
4555
+ confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
4556
+ actionSheet: function(options) { return request('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); }
4557
+ };
4558
+ }
4559
+ })();
4560
+ """)
4333
4561
  // `AudioContext` and the rest of Web Audio, rendered natively.
4334
4562
  audio.install(in: jsContext)
4335
4563
  // `crypto` for code shared with the page (a sign-in's nonce and its
@@ -4772,7 +5000,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4772
5000
  else if let symbol = spec["symbol"] as? String { button.setImage(UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) }
4773
5001
  button.accessibilityIdentifier = spec["id"] as? String
4774
5002
  button.accessibilityLabel = spec["accessibilityLabel"] as? String
4775
- if let value = spec["badge"] as? NSNumber, value.intValue > 0 {
5003
+ if !(spec["badge"] is Bool), let value = spec["badge"] as? NSNumber, value.intValue > 0 {
4776
5004
  let dot = spec["badgeStyle"] as? String == "dot"
4777
5005
  let size: CGFloat = dot ? 9 : 18
4778
5006
  // The fill is a tinted symbol, not a background colour: on iOS 26
@@ -5524,7 +5752,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5524
5752
  result.isUserInteractionEnabled = false
5525
5753
  case "Text":
5526
5754
  let label = result as! UILabel
5527
- label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
5755
+ label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
5528
5756
  label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
5529
5757
  (label as? CraftNativeLabel)?.insets = paddingInsets(style)
5530
5758
  configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
@@ -5598,7 +5826,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5598
5826
  field.tintColor = color(props["selectionColor"])
5599
5827
  let fieldId = ObjectIdentifier(field)
5600
5828
  inputIdentities[fieldId] = current.identity
5601
- if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
5829
+ let maxLength = Int(number(props["maxLength"]) ?? 0)
5830
+ if maxLength > 0 {
5602
5831
  textMaxLengths[fieldId] = maxLength
5603
5832
  } else {
5604
5833
  textMaxLengths.removeValue(forKey: fieldId)
@@ -5681,7 +5910,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5681
5910
  if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
5682
5911
  }
5683
5912
  // UIKit takes an interval that divides an hour, and ignores others.
5684
- let interval = (props["minuteInterval"] as? NSNumber)?.intValue ?? 1
5913
+ let interval = Int(number(props["minuteInterval"]) ?? 1)
5685
5914
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
5686
5915
  picker.minimumDate = Self.pickerDate(props["minimumDate"])
5687
5916
  picker.maximumDate = Self.pickerDate(props["maximumDate"])
@@ -5737,7 +5966,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5737
5966
  pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
5738
5967
  case "Switch":
5739
5968
  let toggle = result as! UISwitch
5740
- toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
5969
+ let value = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
5970
+ // UIKit has already changed the value before emitting the event.
5971
+ // Echoing it through the script must not restart its native animation.
5972
+ if toggle.isOn != value { toggle.isOn = value }
5741
5973
  toggle.isEnabled = props["disabled"] as? Bool != true
5742
5974
  if let track = props["trackColor"] as? [String: Any] {
5743
5975
  toggle.onTintColor = color(track["true"])
@@ -5751,13 +5983,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5751
5983
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
5752
5984
  case "Slider":
5753
5985
  let slider = result as! UISlider
5754
- let minimum = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
5986
+ let minimum = Float(number(props["minimumValue"]) ?? 0)
5755
5987
  slider.minimumValue = minimum.isFinite ? minimum : 0
5756
- let maximum = (props["maximumValue"] as? NSNumber)?.floatValue ?? 1
5988
+ let maximum = Float(number(props["maximumValue"]) ?? 1)
5757
5989
  slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, maximum.isFinite ? maximum : 1)
5758
- let authoredValue = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
5990
+ let authoredValue = Float(number(props["value"]) ?? CGFloat(slider.minimumValue))
5759
5991
  let value = authoredValue.isFinite ? authoredValue : slider.minimumValue
5760
- if let step = (props["step"] as? NSNumber)?.floatValue, step.isFinite, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
5992
+ if let step = number(props["step"]).map(Float.init), step.isFinite, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
5761
5993
  else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
5762
5994
  slider.value = snappedSliderValue(value, for: slider)
5763
5995
  slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
@@ -5771,8 +6003,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5771
6003
  let requestedSize = props["size"]
5772
6004
  indicator.style = (requestedSize as? String) == "large" ? .large : .medium
5773
6005
  let scale: CGFloat
5774
- if let numericSize = requestedSize as? NSNumber {
5775
- let value = CGFloat(numericSize.doubleValue / 20.0)
6006
+ if let numericSize = number(requestedSize) {
6007
+ let value = numericSize / 20.0
5776
6008
  scale = value.isFinite ? max(0.5, value) : 1
5777
6009
  } else if (requestedSize as? String) == "small" {
5778
6010
  scale = 0.75
@@ -6230,7 +6462,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6230
6462
  list.apply(
6231
6463
  nodes: children,
6232
6464
  horizontal: props["horizontal"] as? Bool == true,
6233
- columns: (props["numColumns"] as? NSNumber)?.intValue ?? 1,
6465
+ columns: Int(number(props["numColumns"]) ?? 1),
6234
6466
  inverted: props["inverted"] as? Bool == true,
6235
6467
  endReachedThreshold: threshold,
6236
6468
  renderItem: { [weak self] node, identity, _ in
@@ -6591,12 +6823,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6591
6823
  textView.textContainer.lineFragmentPadding = 0
6592
6824
  }
6593
6825
  textView.isScrollEnabled = false
6594
- if let numberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue, numberOfLines > 0 {
6826
+ let numberOfLines = Int(number(props["numberOfLines"]) ?? 0)
6827
+ if numberOfLines > 0 {
6595
6828
  textView.textContainer.maximumNumberOfLines = numberOfLines
6596
6829
  } else {
6597
6830
  textView.textContainer.maximumNumberOfLines = 0
6598
6831
  }
6599
- if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
6832
+ let maxLength = Int(number(props["maxLength"]) ?? 0)
6833
+ if maxLength > 0 {
6600
6834
  textMaxLengths[id] = maxLength
6601
6835
  } else {
6602
6836
  textMaxLengths.removeValue(forKey: id)
@@ -7617,7 +7851,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7617
7851
  }
7618
7852
 
7619
7853
  private func number(_ value: Any?) -> CGFloat? {
7620
- guard let number = value as? NSNumber else { return nil }
7854
+ guard !(value is Bool), let number = value as? NSNumber else { return nil }
7621
7855
  let value = CGFloat(truncating: number)
7622
7856
  return value.isFinite ? value : nil
7623
7857
  }
@@ -87,6 +87,13 @@ final class DriverTests: XCTestCase {
87
87
  XCTAssertTrue(ok, what)
88
88
  }
89
89
 
90
+ /// testIDs are exact identifiers. Resolving a unique element with the
91
+ /// subscript scans the whole accessibility tree for ambiguous matches;
92
+ /// firstMatch can stop at the identified node, including a scrolled row.
93
+ func identified(_ id: String) -> XCUIElement {
94
+ app.descendants(matching: .any).matching(NSPredicate(format: "identifier == %@", id)).firstMatch
95
+ }
96
+
90
97
  func element(containing text: String, in target: XCUIApplication? = nil) -> XCUIElement? {
91
98
  let root = target ?? app
92
99
  let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR value CONTAINS[c] %@ OR identifier == %@", text, text, text)
@@ -197,7 +204,7 @@ final class DriverTests: XCTestCase {
197
204
  switch command {
198
205
  case "ifid":
199
206
  let rest = arg.split(separator: " ", maxSplits: 1).map(String.init)
200
- if rest.count == 2, app.descendants(matching: .any)[rest[0]].waitForExistence(timeout: 3) { note("ifid \(rest[0]): shown"); run(rest[1]) }
207
+ if rest.count == 2, identified(rest[0]).waitForExistence(timeout: 3) { note("ifid \(rest[0]): shown"); run(rest[1]) }
201
208
  else { note("ifid \(rest.first ?? ""): not shown") }
202
209
  case "launch": app.launch()
203
210
  case "activate": app.activate()
@@ -261,7 +268,7 @@ final class DriverTests: XCTestCase {
261
268
  if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
262
269
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
263
270
  case "tapid":
264
- let target = app.descendants(matching: .any)[arg]
271
+ let target = identified(arg)
265
272
  if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
266
273
  else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
267
274
  case "tapxy":
@@ -281,7 +288,7 @@ final class DriverTests: XCTestCase {
281
288
  case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
282
289
  case "typeid":
283
290
  let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
284
- let target = app.descendants(matching: .any)[idParts[0]]
291
+ let target = identified(idParts[0])
285
292
  if target.waitForExistence(timeout: 10) {
286
293
  target.tap()
287
294
  Thread.sleep(forTimeInterval: 0.8)
@@ -306,15 +313,15 @@ final class DriverTests: XCTestCase {
306
313
  check(poll(seconds, { exact(text) }) != nil, "expectexact \(text)")
307
314
  case "expectid":
308
315
  let (id, seconds) = split(arg)
309
- check(app.descendants(matching: .any)[id].waitForExistence(timeout: seconds), "expectid \(id)")
316
+ check(identified(id).waitForExistence(timeout: seconds), "expectid \(id)")
310
317
  case "labelid":
311
318
  let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
312
- let target = app.descendants(matching: .any)[idParts[0]]
319
+ let target = identified(idParts[0])
313
320
  let want = idParts.count > 1 ? idParts[1] : ""
314
321
  let seen = target.waitForExistence(timeout: 5) ? target.label : "(missing)"
315
322
  check(seen == want, "labelid \(idParts[0]) \(want) (saw \(seen))")
316
323
  case "absentid":
317
- check(!app.descendants(matching: .any)[arg].exists, "absentid \(arg)")
324
+ check(!identified(arg).exists, "absentid \(arg)")
318
325
  case "absent":
319
326
  check(element(containing: arg) == nil, "absent \(arg)")
320
327
  case "tabbar":
@@ -98,6 +98,10 @@ pub const Style = struct {
98
98
  grid_columns: u32 = 1,
99
99
  row_gap: f32 = 0,
100
100
  column_gap: f32 = 0,
101
+ /// Minimum height for generated grid rows. This is optional in the tree
102
+ /// ABI and remains host-neutral because hosts resolve percentages before
103
+ /// serializing the style.
104
+ grid_auto_rows: ?f32 = null,
101
105
  };
102
106
 
103
107
  pub const Node = struct {
@@ -243,6 +247,53 @@ pub const LayoutNodeInputV2 = extern struct {
243
247
  child_count: u32,
244
248
  };
245
249
 
250
+ /// Versioned tree input that carries the resolved `grid-auto-rows` minimum.
251
+ /// Appending the field keeps the v1/v2/v3 layouts ABI-compatible for older
252
+ /// hosts while allowing a native tree to use the same auto-row rule on every
253
+ /// platform.
254
+ pub const LayoutStyleInputV4 = extern struct {
255
+ direction: u32,
256
+ width: f32,
257
+ height: f32,
258
+ intrinsic_width: f32,
259
+ intrinsic_height: f32,
260
+ baseline: f32,
261
+ aspect_ratio: f32,
262
+ flex_basis: f32,
263
+ flex_grow: f32,
264
+ flex_shrink: f32,
265
+ min_width: f32,
266
+ max_width: f32,
267
+ min_height: f32,
268
+ max_height: f32,
269
+ gap: f32,
270
+ justify_content: u32,
271
+ align_items: u32,
272
+ align_self: u32,
273
+ flex_wrap: u32,
274
+ align_content: u32,
275
+ margin_top: f32,
276
+ margin_right: f32,
277
+ margin_bottom: f32,
278
+ margin_left: f32,
279
+ padding_top: f32,
280
+ padding_right: f32,
281
+ padding_bottom: f32,
282
+ padding_left: f32,
283
+ grid: u32,
284
+ grid_columns: u32,
285
+ row_gap: f32,
286
+ column_gap: f32,
287
+ reverse: u32,
288
+ grid_auto_rows: f32,
289
+ };
290
+
291
+ pub const LayoutNodeInputV4 = extern struct {
292
+ style: LayoutStyleInputV4,
293
+ first_child: u32,
294
+ child_count: u32,
295
+ };
296
+
246
297
  const max_layout_nodes: usize = 65_536;
247
298
 
248
299
  /// Resolve an entire native view tree using the same layout rules used by the
@@ -361,6 +412,43 @@ pub export fn craft_native_layout_tree_v3(
361
412
  return if (result == node_count) 0 else -1;
362
413
  }
363
414
 
415
+ /// v4 tree layout entry point. It preserves v3's reverse/wrap-reverse bits
416
+ /// and adds one resolved `grid-auto-rows` minimum per node.
417
+ pub export fn craft_native_layout_tree_v4(
418
+ viewport_width: f32,
419
+ viewport_height: f32,
420
+ nodes: [*]const LayoutNodeInputV4,
421
+ node_count: usize,
422
+ frames: [*]Frame,
423
+ frame_count: usize,
424
+ ) callconv(.c) c_int {
425
+ if (node_count == 0 or node_count > max_layout_nodes or frame_count < node_count) return -1;
426
+
427
+ const input = nodes[0..node_count];
428
+ const visited = std.heap.page_allocator.alloc(bool, node_count) catch return -1;
429
+ defer std.heap.page_allocator.free(visited);
430
+ @memset(visited, false);
431
+ if (!validateLayoutTreeV4(input, 0, visited)) return -1;
432
+
433
+ var tree = std.heap.page_allocator.alloc(Node, node_count) catch return -1;
434
+ defer std.heap.page_allocator.free(tree);
435
+ for (input, 0..) |item, index| {
436
+ const first = @as(usize, @intCast(item.first_child));
437
+ const count = @as(usize, @intCast(item.child_count));
438
+ tree[index] = .{
439
+ .style = layoutStyleV4(item.style),
440
+ .children = tree[first .. first + count],
441
+ };
442
+ }
443
+
444
+ const result = layout(
445
+ tree[0],
446
+ .{ .width = finiteNonNegative(viewport_width), .height = finiteNonNegative(viewport_height) },
447
+ frames[0..frame_count],
448
+ ) catch return -1;
449
+ return if (result == node_count) 0 else -1;
450
+ }
451
+
364
452
  fn validateLayoutTree(input: []const LayoutNodeInput, index: usize, visited: []bool) bool {
365
453
  if (index >= input.len or visited[index]) return false;
366
454
  visited[index] = true;
@@ -387,6 +475,19 @@ fn validateLayoutTreeV2(input: []const LayoutNodeInputV2, index: usize, visited:
387
475
  return true;
388
476
  }
389
477
 
478
+ fn validateLayoutTreeV4(input: []const LayoutNodeInputV4, index: usize, visited: []bool) bool {
479
+ if (index >= input.len or visited[index]) return false;
480
+ visited[index] = true;
481
+ const item = input[index];
482
+ const first = @as(usize, @intCast(item.first_child));
483
+ const count = @as(usize, @intCast(item.child_count));
484
+ if (first > input.len or count > input.len - first or (count > 0 and first <= index)) return false;
485
+ for (first..first + count) |child| {
486
+ if (!validateLayoutTreeV4(input, child, visited)) return false;
487
+ }
488
+ return true;
489
+ }
490
+
390
491
  fn layoutStyle(input: LayoutStyleInput) Style {
391
492
  return .{
392
493
  .direction = if (input.direction == 1) .column else .row,
@@ -470,6 +571,46 @@ fn layoutStyleV3(input: LayoutStyleInputV2) Style {
470
571
  return style;
471
572
  }
472
573
 
574
+ fn layoutStyleV4(input: LayoutStyleInputV4) Style {
575
+ return .{
576
+ .direction = if (input.direction == 1) .column else .row,
577
+ .reverse = (input.reverse & 1) != 0,
578
+ .wrap_reverse = (input.reverse & 2) != 0,
579
+ .width = optionalFinite(input.width),
580
+ .height = optionalFinite(input.height),
581
+ .intrinsic_width = optionalFinite(input.intrinsic_width),
582
+ .intrinsic_height = optionalFinite(input.intrinsic_height),
583
+ .baseline = optionalFinite(input.baseline),
584
+ .aspect_ratio = optionalFinite(input.aspect_ratio),
585
+ .flex_basis = optionalFinite(input.flex_basis),
586
+ .flex_grow = finiteNonNegative(input.flex_grow),
587
+ .flex_shrink = finiteNonNegative(input.flex_shrink),
588
+ .min_width = optionalFinite(input.min_width),
589
+ .max_width = optionalFinite(input.max_width),
590
+ .min_height = optionalFinite(input.min_height),
591
+ .max_height = optionalFinite(input.max_height),
592
+ .gap = finiteNonNegative(input.gap),
593
+ .justify_content = @fromBackingInt(@intCast(@min(input.justify_content, 5))),
594
+ .align_items = @fromBackingInt(@intCast(@min(input.align_items, 4))),
595
+ .align_self = if (input.align_self == 0) null else @fromBackingInt(@intCast(@min(input.align_self - 1, 4))),
596
+ .flex_wrap = input.flex_wrap != 0,
597
+ .align_content = @fromBackingInt(@intCast(@min(input.align_content, 6))),
598
+ .margin_top = finiteNonNegative(input.margin_top),
599
+ .margin_right = finiteNonNegative(input.margin_right),
600
+ .margin_bottom = finiteNonNegative(input.margin_bottom),
601
+ .margin_left = finiteNonNegative(input.margin_left),
602
+ .padding_top = finiteNonNegative(input.padding_top),
603
+ .padding_right = finiteNonNegative(input.padding_right),
604
+ .padding_bottom = finiteNonNegative(input.padding_bottom),
605
+ .padding_left = finiteNonNegative(input.padding_left),
606
+ .grid = input.grid != 0,
607
+ .grid_columns = @max(@as(u32, 1), input.grid_columns),
608
+ .row_gap = finiteNonNegative(input.row_gap),
609
+ .column_gap = finiteNonNegative(input.column_gap),
610
+ .grid_auto_rows = optionalFinite(input.grid_auto_rows),
611
+ };
612
+ }
613
+
473
614
  fn optionalFinite(value: f32) ?f32 {
474
615
  return if (std.math.isFinite(value)) value else null;
475
616
  }
@@ -1267,7 +1408,7 @@ fn gridRowHeight(node: Node, row: usize, column_count: usize) f32 {
1267
1408
  const child_height = child.style.height orelse ratio_height orelse child.style.intrinsic_height orelse child.style.flex_basis orelse 0;
1268
1409
  height = @max(height, clamp(child_height, child.style.min_height, child.style.max_height) + verticalMargins(child));
1269
1410
  }
1270
- return height;
1411
+ return @max(height, finiteNonNegative(node.style.grid_auto_rows orelse 0));
1271
1412
  }
1272
1413
 
1273
1414
  fn childMainBasis(node: Node, parent_direction: Axis) f32 {
@@ -1524,6 +1665,46 @@ fn emptyLayoutStyleInputV2() LayoutStyleInputV2 {
1524
1665
  };
1525
1666
  }
1526
1667
 
1668
+ fn emptyLayoutStyleInputV4() LayoutStyleInputV4 {
1669
+ const unset = std.math.nan(f32);
1670
+ return .{
1671
+ .direction = 0,
1672
+ .width = unset,
1673
+ .height = unset,
1674
+ .intrinsic_width = unset,
1675
+ .intrinsic_height = unset,
1676
+ .baseline = unset,
1677
+ .aspect_ratio = unset,
1678
+ .flex_basis = unset,
1679
+ .flex_grow = 0,
1680
+ .flex_shrink = 0,
1681
+ .min_width = unset,
1682
+ .max_width = unset,
1683
+ .min_height = unset,
1684
+ .max_height = unset,
1685
+ .gap = 0,
1686
+ .justify_content = 0,
1687
+ .align_items = 3,
1688
+ .align_self = 0,
1689
+ .flex_wrap = 0,
1690
+ .align_content = 6,
1691
+ .margin_top = 0,
1692
+ .margin_right = 0,
1693
+ .margin_bottom = 0,
1694
+ .margin_left = 0,
1695
+ .padding_top = 0,
1696
+ .padding_right = 0,
1697
+ .padding_bottom = 0,
1698
+ .padding_left = 0,
1699
+ .grid = 0,
1700
+ .grid_columns = 1,
1701
+ .row_gap = 0,
1702
+ .column_gap = 0,
1703
+ .reverse = 0,
1704
+ .grid_auto_rows = unset,
1705
+ };
1706
+ }
1707
+
1527
1708
  fn clampBounds(value: f32, lower: f32, upper: f32) f32 {
1528
1709
  var result = finiteNonNegative(value);
1529
1710
  if (std.math.isFinite(lower) and lower >= 0) result = @max(result, lower);
@@ -1566,6 +1747,74 @@ test "exports the host-neutral tree layout contract" {
1566
1747
  try std.testing.expectEqual(@as(f32, 10), frames[1].height);
1567
1748
  }
1568
1749
 
1750
+ test "v4 tree layout applies a resolved grid auto-row minimum" {
1751
+ var root_style = emptyLayoutStyleInputV4();
1752
+ root_style.width = 100;
1753
+ root_style.height = 100;
1754
+ root_style.grid = 1;
1755
+ root_style.grid_columns = 2;
1756
+ root_style.grid_auto_rows = 20;
1757
+
1758
+ var first_style = emptyLayoutStyleInputV4();
1759
+ first_style.intrinsic_width = 10;
1760
+ first_style.intrinsic_height = 5;
1761
+ var second_style = emptyLayoutStyleInputV4();
1762
+ second_style.intrinsic_width = 10;
1763
+ second_style.intrinsic_height = 7;
1764
+ var third_style = emptyLayoutStyleInputV4();
1765
+ third_style.intrinsic_width = 10;
1766
+ third_style.intrinsic_height = 4;
1767
+
1768
+ const nodes = [_]LayoutNodeInputV4{
1769
+ .{ .style = root_style, .first_child = 1, .child_count = 3 },
1770
+ .{ .style = first_style, .first_child = 0, .child_count = 0 },
1771
+ .{ .style = second_style, .first_child = 0, .child_count = 0 },
1772
+ .{ .style = third_style, .first_child = 0, .child_count = 0 },
1773
+ };
1774
+ var frames: [4]Frame = undefined;
1775
+ try std.testing.expectEqual(@as(c_int, 0), craft_native_layout_tree_v4(100, 100, &nodes, nodes.len, &frames, frames.len));
1776
+ try std.testing.expectEqual(@as(f32, 0), frames[1].y);
1777
+ try std.testing.expectEqual(@as(f32, 0), frames[2].y);
1778
+ try std.testing.expectEqual(@as(f32, 20), frames[3].y);
1779
+ }
1780
+
1781
+ test "v4 tree layout carries auto rows through nested grids" {
1782
+ var root_style = emptyLayoutStyleInputV4();
1783
+ root_style.width = 100;
1784
+ root_style.height = 100;
1785
+
1786
+ var grid_style = emptyLayoutStyleInputV4();
1787
+ grid_style.width = 100;
1788
+ grid_style.height = 100;
1789
+ grid_style.grid = 1;
1790
+ grid_style.grid_columns = 2;
1791
+ grid_style.grid_auto_rows = 18;
1792
+
1793
+ var first_style = emptyLayoutStyleInputV4();
1794
+ first_style.intrinsic_width = 10;
1795
+ first_style.intrinsic_height = 4;
1796
+ var second_style = emptyLayoutStyleInputV4();
1797
+ second_style.intrinsic_width = 10;
1798
+ second_style.intrinsic_height = 6;
1799
+ var third_style = emptyLayoutStyleInputV4();
1800
+ third_style.intrinsic_width = 10;
1801
+ third_style.intrinsic_height = 5;
1802
+
1803
+ const nodes = [_]LayoutNodeInputV4{
1804
+ .{ .style = root_style, .first_child = 1, .child_count = 1 },
1805
+ .{ .style = grid_style, .first_child = 2, .child_count = 3 },
1806
+ .{ .style = first_style, .first_child = 0, .child_count = 0 },
1807
+ .{ .style = second_style, .first_child = 0, .child_count = 0 },
1808
+ .{ .style = third_style, .first_child = 0, .child_count = 0 },
1809
+ };
1810
+ var frames: [5]Frame = undefined;
1811
+ try std.testing.expectEqual(@as(c_int, 0), craft_native_layout_tree_v4(100, 100, &nodes, nodes.len, &frames, frames.len));
1812
+ try std.testing.expectEqual(@as(f32, 0), frames[1].x);
1813
+ try std.testing.expectEqual(@as(f32, 0), frames[2].y);
1814
+ try std.testing.expectEqual(@as(f32, 0), frames[3].y);
1815
+ try std.testing.expectEqual(@as(f32, 18), frames[4].y);
1816
+ }
1817
+
1569
1818
  test "lays out nested flat trees with absolute descendant frames" {
1570
1819
  var root_style = emptyLayoutStyleInput();
1571
1820
  root_style.width = 200;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.212",
3
+ "version": "0.0.214",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",