craft-native 0.0.500 → 0.0.503

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.
@@ -47,6 +47,12 @@ export interface CraftAndroidConfig {
47
47
  renderer?: 'web' | 'native';
48
48
  /** "system" follows the device; light/dark pin the generated host. */
49
49
  appearance?: 'light' | 'dark' | 'system';
50
+ /**
51
+ * The native chrome's accent as #RRGGBB: Back, the bar's buttons and the
52
+ * selected tab. Omitted keeps the system blue, as before. iOS has had the
53
+ * same key; without it an app themed on iOS drew its Android chrome blue.
54
+ */
55
+ tintColor?: string;
50
56
  /** Static native tab-bar styling applied before the page's own tabBar.set call. */
51
57
  tabBar?: CraftTabBarConfig;
52
58
  /** Runs from the generated project root before Gradle compiles the app. */
@@ -1140,6 +1140,8 @@ function validateAndroidConfig(config) {
1140
1140
  if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
1141
1141
  throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
1142
1142
  }
1143
+ if (config.tintColor !== undefined && !/^#[0-9a-f]{6}$/i.test(config.tintColor))
1144
+ throw new Error("Android tintColor must be a #RRGGBB color");
1143
1145
  const selection = config.tabBar?.selection;
1144
1146
  if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1145
1147
  throw new Error("Android tabBar.selection.cornerRadius must be a finite non-negative number");
@@ -17,6 +17,9 @@ import kotlin.math.max
17
17
 
18
18
  internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
19
19
 
20
+ /** The most columns a grid is given: more than any screen has pixels across to show. */
21
+ internal const val CRAFT_NATIVE_FLAT_LIST_MAX_COLUMNS = 1024
22
+
20
23
  /** A stable-key RecyclerView host for stx native FlatList nodes. */
21
24
  internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
22
25
  override fun dispatchTouchEvent(event: MotionEvent): Boolean =
@@ -403,7 +406,11 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
403
406
  this.endReached = endReached
404
407
  threshold = endReachedThreshold.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.1
405
408
 
406
- val nextColumns = columns.coerceAtLeast(1)
409
+ // A grid keeps an array one longer than its column count, so a
410
+ // count from data (`numColumns={Number.MAX_VALUE}` arrives as the
411
+ // largest Int) asked it for a negative length and ended the process
412
+ // in layout. No screen is as many pixels wide as this many columns.
413
+ val nextColumns = columns.coerceIn(1, CRAFT_NATIVE_FLAT_LIST_MAX_COLUMNS)
407
414
  val layoutChanged = this.horizontal != horizontal || this.columns != nextColumns
408
415
  val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
409
416
  this.horizontal = horizontal
@@ -187,6 +187,10 @@ internal class CraftNativeRoutes(config: JSONObject) {
187
187
  }
188
188
  ?: CraftNativeTabBarAppearance()
189
189
 
190
+ /** The chrome's accent, where the app set one (`tintColor`); the system blue otherwise. */
191
+ val tintColor: Int? = config.optString("tintColor").takeUnless { config.isNull("tintColor") || it.isBlank() }
192
+ ?.let { runCatching { Color.parseColor(it) }.getOrNull() }
193
+
190
194
  /** CSS stacks for the chrome's labels and titles, as the app configured them. */
191
195
  val fontFamily: String? = config.optString("fontFamily").takeUnless { config.isNull("fontFamily") || it.isBlank() }
192
196
  val headingFontFamily: String? = config.optString("headingFontFamily").takeUnless { config.isNull("headingFontFamily") || it.isBlank() }
@@ -993,7 +997,7 @@ internal object CraftNativePrelude {
993
997
  * screen sets its own chrome with
994
998
  *
995
999
  * `craft.navigation.setOptions({ title, largeTitle, titleColor, hidden,
996
- * backgroundColor, leftButtons, rightButtons, tabItem, tabAccessory,
1000
+ * backgroundColor, backButton, leftButtons, rightButtons, tabItem, tabAccessory,
997
1001
  * tabBarHidden, tabBarHiddenWhenPushed, tabSwipeEnabled })`
998
1002
  *
999
1003
  * which `apply` below reads. That list is checked against `apply` itself
@@ -1013,6 +1017,7 @@ internal class CraftNativeChrome(
1013
1017
  private val tabs: List<CraftNativeTab>,
1014
1018
  private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
1015
1019
  private val fonts: CraftNativeChromeFonts = CraftNativeChromeFonts(),
1020
+ private val tintColor: Int? = null,
1016
1021
  ) : LinearLayout(context) {
1017
1022
  val content = FrameLayout(context)
1018
1023
  private val body = FrameLayout(context)
@@ -1249,6 +1254,8 @@ internal class CraftNativeChrome(
1249
1254
  val title = options.optString("title").takeIf { options.has("title") } ?: fallbackTitle.orEmpty()
1250
1255
  bar.visibility = if (hidden) View.GONE else View.VISIBLE
1251
1256
  backButton.visibility = if (depth > 0) View.VISIBLE else View.GONE
1257
+ backButtonSpec = options.optJSONObject("backButton")
1258
+ applyBackButton()
1252
1259
  bar.setPadding(if (depth > 0) dp(4) else dp(4), 0, dp(8), 0)
1253
1260
  titleView.text = if (large) "" else title
1254
1261
  largeTitleView.text = title
@@ -1368,14 +1375,22 @@ internal class CraftNativeChrome(
1368
1375
  view.getLocationInWindow(at)
1369
1376
  return (at[1] - origin[1] + view.height - top).coerceIn(0, reach)
1370
1377
  }
1371
- fun visit(view: View) {
1378
+ // `inside` a scroll view that scrolls down the screen, a list or a
1379
+ // second scroll view is brought clear by that one, and gets none of
1380
+ // its own: where it sits in a page not yet scrolled to it says
1381
+ // nothing about the screen, and a short list below the fold was
1382
+ // given the whole reach and scrolled inside its box into blank space.
1383
+ fun visit(view: View, inside: Boolean) {
1372
1384
  when (view) {
1373
- is CraftNativeFlatList -> view.setFloatingCover(cover(view))
1374
- is CraftNativeScrollView -> { view.setFloatingCover(cover(view)); visit(view.content) }
1375
- is ViewGroup -> for (index in 0 until view.childCount) visit(view.getChildAt(index))
1385
+ is CraftNativeFlatList -> view.setFloatingCover(if (inside) 0 else cover(view))
1386
+ is CraftNativeScrollView -> {
1387
+ view.setFloatingCover(if (inside) 0 else cover(view))
1388
+ visit(view.content, inside || view.scrollsVertically)
1389
+ }
1390
+ is ViewGroup -> for (index in 0 until view.childCount) visit(view.getChildAt(index), inside)
1376
1391
  }
1377
1392
  }
1378
- visit(content)
1393
+ visit(content, false)
1379
1394
  }
1380
1395
 
1381
1396
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
@@ -1497,6 +1512,42 @@ internal class CraftNativeChrome(
1497
1512
  )
1498
1513
  }
1499
1514
 
1515
+ private var backButtonSpec: JSONObject? = null
1516
+ private var renderedBackButton = ""
1517
+
1518
+ /**
1519
+ * `backButton`: what this screen's Back is drawn with, when it is not the
1520
+ * arrow (`{ symbol: "xmark", accessibilityLabel: "Close" }` for a screen
1521
+ * that closes rather than goes back). `leftButtons` sit beside Back, so a
1522
+ * screen could not put its own symbol in Back's place (#527). It is still
1523
+ * the bar's back button: the same tap, shown at the same depths.
1524
+ *
1525
+ * The symbol is the app's own icon when it has one, and AppCompat's close
1526
+ * mark for an `xmark` it has none for. Any other symbol the app has no
1527
+ * icon for leaves the arrow, and its "Back".
1528
+ */
1529
+ private fun applyBackButton() {
1530
+ val symbol = backButtonSpec?.optString("symbol").orEmpty()
1531
+ val label = backButtonSpec?.optString("accessibilityLabel").orEmpty()
1532
+ val tint = backButton.imageTintList?.defaultColor ?: Color.BLACK
1533
+ // The activity passes cumulative options for every live patch: the
1534
+ // same symbol in the same colour keeps the picture it has.
1535
+ val rendered = "$symbol\u0000$label\u0000$tint"
1536
+ if (rendered == renderedBackButton) return
1537
+ renderedBackButton = rendered
1538
+ val icon = if (symbol.isBlank()) null else CraftNativeIcons.drawable(context, null, symbol, tint)
1539
+ ?: if (symbol.startsWith("xmark")) {
1540
+ androidx.appcompat.content.res.AppCompatResources.getDrawable(context, androidx.appcompat.R.drawable.abc_ic_clear_material)
1541
+ } else null
1542
+ if (icon == null) {
1543
+ backButton.setImageResource(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
1544
+ backButton.contentDescription = "Back"
1545
+ } else {
1546
+ backButton.setImageDrawable(icon)
1547
+ backButton.contentDescription = label.ifBlank { if (symbol.startsWith("xmark")) "Close" else "Back" }
1548
+ }
1549
+ }
1550
+
1500
1551
  /** Light or dark changed: the bars follow the screen's background. */
1501
1552
  fun refreshColors() {
1502
1553
  val background = screenBackgroundColor ?: ContextCompat.getColor(context, R.color.background)
@@ -1510,6 +1561,8 @@ internal class CraftNativeChrome(
1510
1561
  titleView.setTextColor(navigationTitleColor ?: text)
1511
1562
  largeTitleView.setTextColor(navigationTitleColor ?: text)
1512
1563
  backButton.imageTintList = ColorStateList.valueOf(accent)
1564
+ // A screen's own back symbol is drawn in the accent, not tinted by it.
1565
+ applyBackButton()
1513
1566
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1514
1567
  val grouped = accessoryGroup.parent === body
1515
1568
  accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
@@ -1633,7 +1686,8 @@ internal class CraftNativeChrome(
1633
1686
  }
1634
1687
  }
1635
1688
 
1636
- private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1689
+ /** Back, the bar's buttons and the selected tab: the app's own tint in both appearances, else the system blue. */
1690
+ private fun accentColor(dark: Boolean): Int = tintColor ?: if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1637
1691
 
1638
1692
  private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1639
1693
  if (accessorySelected || index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
@@ -229,6 +229,21 @@ internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback:
229
229
  return if (value.isFinite() && narrowed.isFinite()) narrowed else fallback
230
230
  }
231
231
 
232
+ /**
233
+ * The most a number may be on a view, either side of zero: 2^25, as on UIKit.
234
+ * It is far past any screen, and a view refuses anything past the largest
235
+ * Float: `setScaleX` and its like throw on infinity and on what is not a
236
+ * number, and a style is whatever the script said.
237
+ */
238
+ internal const val CRAFT_NATIVE_LAYOUT_LIMIT = 33_554_432f
239
+
240
+ /** A number for a view: inside that limit, and 0 when it is not a number. */
241
+ internal fun craftNativeLayoutNumber(value: Float): Float =
242
+ if (value.isNaN()) 0f else value.coerceIn(-CRAFT_NATIVE_LAYOUT_LIMIT, CRAFT_NATIVE_LAYOUT_LIMIT)
243
+
244
+ internal fun craftNativeLayoutNumber(value: Double): Float =
245
+ if (value.isNaN()) 0f else value.coerceIn(-CRAFT_NATIVE_LAYOUT_LIMIT.toDouble(), CRAFT_NATIVE_LAYOUT_LIMIT.toDouble()).toFloat()
246
+
232
247
  internal fun craftNativeFiniteDouble(style: JSONObject?, name: String, fallback: Double): Double {
233
248
  val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
234
249
  return value.takeIf { it.isFinite() } ?: fallback
@@ -355,29 +370,32 @@ internal fun craftNativeComponentLayoutStyle(node: JSONObject): JSONObject {
355
370
  return merged
356
371
  }
357
372
 
358
- private fun craftNativeGridColumnCount(value: Any?): Int {
359
- if (value is Number) return max(1, value.toInt())
360
- val text = value as? String ?: return 1
361
- val trimmed = text.trim()
362
- if (trimmed.startsWith("repeat(")) {
363
- val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
364
- if (count != null) return max(1, count)
365
- }
366
- return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
367
- }
373
+ /**
374
+ * The most tracks a grid is given, either way. A count is whatever the script
375
+ * said (`gridTemplateRows: Number.MAX_VALUE`, `repeat(2147483647, 1fr)`), and
376
+ * the layout makes a list of rows and an array of columns that long: the
377
+ * process ran out of memory in measure. No screen has room to show more
378
+ * tracks than this.
379
+ */
380
+ internal const val CRAFT_NATIVE_GRID_TRACK_LIMIT = 1024
368
381
 
369
- private fun craftNativeGridRowCount(value: Any?): Int? {
370
- if (value is Number) return max(1, value.toInt())
371
- val text = value as? String ?: return null
372
- val trimmed = text.trim()
373
- if (trimmed.isEmpty()) return null
382
+ internal fun craftNativeGridTrackCount(value: Any?): Int? {
383
+ if (value is Number) return value.toInt().coerceIn(1, CRAFT_NATIVE_GRID_TRACK_LIMIT)
384
+ val trimmed = (value as? String)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
374
385
  if (trimmed.startsWith("repeat(")) {
375
- val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
376
- if (count != null) return max(1, count)
386
+ // Digits past what an Int holds are still a count, and still too many.
387
+ val digits = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }
388
+ if (digits.isNotEmpty()) {
389
+ return (digits.toIntOrNull() ?: CRAFT_NATIVE_GRID_TRACK_LIMIT).coerceIn(1, CRAFT_NATIVE_GRID_TRACK_LIMIT)
390
+ }
377
391
  }
378
- return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
392
+ return trimmed.split(Regex("\\s+")).count { it.isNotBlank() }.coerceIn(1, CRAFT_NATIVE_GRID_TRACK_LIMIT)
379
393
  }
380
394
 
395
+ private fun craftNativeGridColumnCount(value: Any?): Int = craftNativeGridTrackCount(value) ?: 1
396
+
397
+ private fun craftNativeGridRowCount(value: Any?): Int? = craftNativeGridTrackCount(value)
398
+
381
399
  /**
382
400
  * A CSS colour as Android's ARGB. STX writes `#RRGGBBAA` (and `#RGBA`) for a
383
401
  * colour with alpha, where Android's parser reads `#AARRGGBB`: a translucent
@@ -412,6 +430,54 @@ internal fun craftNativeColor(raw: String): Int {
412
430
  }
413
431
 
414
432
  /** Android View has no skew setters before API 29; draw the shared skew around the view's center. */
433
+ /**
434
+ * Times a hold on a view whose touch listener answers every event itself.
435
+ *
436
+ * The platform times a long click in `View.onTouchEvent`, and a listener
437
+ * that consumes the touch keeps that from running. A pressable with
438
+ * `onPress` has such a listener (it sends the press, and the press-in and
439
+ * press-out around it), so its `onLongPress`, and the menu a `contextMenu`
440
+ * opens on a hold, were never reached by a finger: holding a row sent its
441
+ * press when the finger lifted (#529).
442
+ *
443
+ * The same rules as the platform's: the hold is up after its long-press
444
+ * timeout, a finger that moves past the touch slop is not holding, and a
445
+ * hold that something handled is not also a press.
446
+ */
447
+ private object CraftNativePressHolds {
448
+ private class Hold(val fire: Runnable, val x: Float, val y: Float)
449
+ private val pending = WeakHashMap<View, Hold>()
450
+ private val handled = WeakHashMap<View, Boolean>()
451
+
452
+ fun began(view: View, event: MotionEvent) {
453
+ cancel(view)
454
+ handled.remove(view)
455
+ if (!view.isLongClickable) return
456
+ val fire = Runnable {
457
+ pending.remove(view)
458
+ if (view.isAttachedToWindow && view.isLongClickable && view.performLongClick()) handled[view] = true
459
+ }
460
+ pending[view] = Hold(fire, event.x, event.y)
461
+ view.postDelayed(fire, android.view.ViewConfiguration.getLongPressTimeout().toLong())
462
+ }
463
+
464
+ fun moved(view: View, event: MotionEvent) {
465
+ val hold = pending[view] ?: return
466
+ val slop = android.view.ViewConfiguration.get(view.context).scaledTouchSlop
467
+ if (abs(event.x - hold.x) > slop || abs(event.y - hold.y) > slop) cancel(view)
468
+ }
469
+
470
+ /** The touch is over. Whether its hold was handled, so that lifting is not a press. */
471
+ fun ended(view: View): Boolean {
472
+ cancel(view)
473
+ return handled.remove(view) == true
474
+ }
475
+
476
+ private fun cancel(view: View) {
477
+ pending.remove(view)?.let { view.removeCallbacks(it.fire) }
478
+ }
479
+ }
480
+
415
481
  private object CraftNativeSkewTransforms {
416
482
  private val values = WeakHashMap<View, Pair<Float, Float>>()
417
483
 
@@ -1344,7 +1410,19 @@ internal class CraftNativePointerImageView(context: Context) : ImageView(context
1344
1410
  ?: style.opt(name)?.takeUnless { it == JSONObject.NULL }
1345
1411
  val fit = value("objectFit")
1346
1412
  val position = value("objectPosition")
1347
- objectFit = if (fit != null || position != null) (fit as? String)
1413
+ // A position does not choose a fit, as in CSS. STX compiles
1414
+ // `object-cover` to `resizeMode: "cover"` and sets no objectFit, so a
1415
+ // cover image given a position was drawn as "fill": stretched to its
1416
+ // box instead of cropped (#532).
1417
+ val requested = if (fit != null) fit as? String else when (
1418
+ craftNativeOptionalString(style.opt("resizeMode")) ?: craftNativeOptionalString(props.opt("resizeMode"))
1419
+ ) {
1420
+ "cover" -> "cover"
1421
+ "contain" -> "contain"
1422
+ "center" -> "none"
1423
+ else -> "fill"
1424
+ }
1425
+ objectFit = if (fit != null || position != null) requested
1348
1426
  ?.takeIf { it in setOf("fill", "contain", "cover", "none", "scale-down") } ?: "fill" else null
1349
1427
  val point = parsePosition(position)
1350
1428
  objectX = point.first; objectY = point.second
@@ -3080,6 +3158,9 @@ internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(cont
3080
3158
  vertical.setPadding(vertical.paddingLeft, vertical.paddingTop, vertical.paddingRight, next)
3081
3159
  }
3082
3160
 
3161
+ /** Whether this scrolls down the screen: what is inside it is brought clear of the actions by it. */
3162
+ val scrollsVertically: Boolean get() = horizontalMode != true
3163
+
3083
3164
  private var bounces = true
3084
3165
  private var alwaysBounceVertical = true
3085
3166
  private var alwaysBounceHorizontal = true
@@ -5345,7 +5426,8 @@ class MainActivity : AppCompatActivity() {
5345
5426
  private fun installChrome() {
5346
5427
  val bodyFont = craftNativeChromeTypeface(routes.fontFamily, bundledFonts)
5347
5428
  val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance,
5348
- CraftNativeChromeFonts(bodyFont, craftNativeChromeTypeface(routes.headingFontFamily, bundledFonts) ?: bodyFont))
5429
+ CraftNativeChromeFonts(bodyFont, craftNativeChromeTypeface(routes.headingFontFamily, bundledFonts) ?: bodyFont),
5430
+ routes.tintColor)
5349
5431
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
5350
5432
  bars.onBack = { onBackPressedDispatcherCompat() }
5351
5433
  bars.onAccessory = { path -> navigateOpen(path) }
@@ -7093,9 +7175,17 @@ class MainActivity : AppCompatActivity() {
7093
7175
  }
7094
7176
  }
7095
7177
  "Slider" -> (previous as? SeekBar ?: CraftNativePointerSeekBar(this)).apply {
7096
- val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0)
7097
- val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0)
7098
- val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
7178
+ // Held to a quarter of the largest Double, so the range, and
7179
+ // a value's distance along it, are numbers: at the largest
7180
+ // their difference is infinite, and infinity over infinity
7181
+ // is not a number, which `roundToInt` throws on and JSON
7182
+ // will not carry back to the screen.
7183
+ val bound = Double.MAX_VALUE / 4
7184
+ val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0).coerceIn(-bound, bound)
7185
+ val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0).coerceIn(-bound, bound)
7186
+ // The next number up, whatever the size of the first: adding
7187
+ // the smallest Double to anything but zero adds nothing.
7188
+ val maximum = if (maximumCandidate > minimum) maximumCandidate else Math.nextUp(minimum)
7099
7189
  setOnSeekBarChangeListener(null)
7100
7190
  val step = craftNativeFiniteNumber(props, "step", 0.0).takeIf { it > 0 }
7101
7191
  // A stepped slider needs one native position per authored
@@ -7111,7 +7201,7 @@ class MainActivity : AppCompatActivity() {
7111
7201
  .toInt()
7112
7202
  } ?: 1000
7113
7203
  max = progressMax
7114
- val requested = craftNativeFiniteNumber(props, "value", minimum)
7204
+ val requested = craftNativeFiniteNumber(props, "value", minimum).coerceIn(minimum, maximum)
7115
7205
  val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
7116
7206
  .coerceIn(minimum, maximum)
7117
7207
  progress = if (step != null) {
@@ -7159,7 +7249,7 @@ class MainActivity : AppCompatActivity() {
7159
7249
  val scale = when (requestedSize) {
7160
7250
  "large" -> 1.35f
7161
7251
  "small" -> 0.75f
7162
- is Number -> (requestedSize.toDouble() / 24.0).takeIf { it.isFinite() }?.toFloat()?.coerceAtLeast(0.5f) ?: 1f
7252
+ is Number -> (requestedSize.toDouble() / 24.0).takeIf { it.isFinite() }?.let { craftNativeLayoutNumber(it).coerceAtLeast(0.5f) } ?: 1f
7163
7253
  else -> 1f
7164
7254
  }
7165
7255
  indicatorScale = scale
@@ -7441,9 +7531,17 @@ class MainActivity : AppCompatActivity() {
7441
7531
  screen.pressUnderlayShowHandlers[view]?.let { current ->
7442
7532
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
7443
7533
  }
7534
+ // This listener answers every event, so the
7535
+ // platform never times the hold: do it here.
7536
+ CraftNativePressHolds.began(view, event)
7537
+ true
7538
+ }
7539
+ android.view.MotionEvent.ACTION_MOVE -> {
7540
+ CraftNativePressHolds.moved(view, event)
7444
7541
  true
7445
7542
  }
7446
7543
  android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
7544
+ val held = CraftNativePressHolds.ended(view)
7447
7545
  view.alpha = screen.pressBaseOpacities[view] ?: 1f
7448
7546
  if (screen.pressBaseBackgrounds.containsKey(view)) view.background = screen.pressBaseBackgrounds[view]
7449
7547
  screen.pressOutHandlers[view]?.let { current ->
@@ -7452,7 +7550,9 @@ class MainActivity : AppCompatActivity() {
7452
7550
  screen.pressUnderlayHideHandlers[view]?.let { current ->
7453
7551
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
7454
7552
  }
7455
- if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
7553
+ // A hold that sent its long press, or opened
7554
+ // its menu, is not a press as well.
7555
+ if (event.actionMasked == android.view.MotionEvent.ACTION_UP && !held) {
7456
7556
  dispatchNativePress(screen, view)
7457
7557
  }
7458
7558
  true
@@ -7964,37 +8064,46 @@ class MainActivity : AppCompatActivity() {
7964
8064
  }
7965
8065
 
7966
8066
  private fun applyTransform(view: View, transforms: JSONArray?) {
7967
- view.translationX = 0f
7968
- view.translationY = 0f
7969
8067
  // Reconciliation reapplies transforms; start from the control's
7970
8068
  // intrinsic scale rather than erasing its authored indicator size.
7971
8069
  val intrinsicScale = (view as? CraftNativeActivityIndicator)?.indicatorScale ?: 1f
7972
- view.scaleX = intrinsicScale
7973
- view.scaleY = intrinsicScale
7974
- view.rotation = 0f
7975
- view.rotationX = 0f
7976
- view.rotationY = 0f
7977
- CraftNativeSkewTransforms.clear(view)
7978
- if (transforms == null) return
8070
+ var translationX = 0f
8071
+ var translationY = 0f
8072
+ var scaleX = intrinsicScale
8073
+ var scaleY = intrinsicScale
8074
+ var rotation = 0f
8075
+ var rotationX = 0f
8076
+ var rotationY = 0f
7979
8077
  var skewX = 0f
7980
8078
  var skewY = 0f
7981
- for (index in 0 until transforms.length()) {
7982
- val transform = transforms.optJSONObject(index) ?: continue
8079
+ for (index in 0 until (transforms?.length() ?: 0)) {
8080
+ val transform = transforms?.optJSONObject(index) ?: continue
7983
8081
  val key = transform.keys().asSequence().firstOrNull() ?: continue
7984
8082
  when (key) {
7985
- "translateX" -> view.translationX += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
7986
- "translateY" -> view.translationY += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
7987
- "scale" -> craftNativeFiniteDouble(transform, key, 1.0).toFloat().let { view.scaleX *= it; view.scaleY *= it }
7988
- "scaleX" -> view.scaleX *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
7989
- "scaleY" -> view.scaleY *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
7990
- "rotate", "rotateZ" -> view.rotation += angleDegrees(transform.opt(key))
7991
- "rotateX" -> view.rotationX += angleDegrees(transform.opt(key))
7992
- "rotateY" -> view.rotationY += angleDegrees(transform.opt(key))
8083
+ "translateX" -> translationX += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
8084
+ "translateY" -> translationY += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
8085
+ "scale" -> craftNativeFiniteDouble(transform, key, 1.0).toFloat().let { scaleX *= it; scaleY *= it }
8086
+ "scaleX" -> scaleX *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
8087
+ "scaleY" -> scaleY *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
8088
+ "rotate", "rotateZ" -> rotation += angleDegrees(transform.opt(key))
8089
+ "rotateX" -> rotationX += angleDegrees(transform.opt(key))
8090
+ "rotateY" -> rotationY += angleDegrees(transform.opt(key))
7993
8091
  "skewX" -> skewX += skewFactor(transform.opt(key))
7994
8092
  "skewY" -> skewY += skewFactor(transform.opt(key))
7995
8093
  }
7996
8094
  }
7997
- CraftNativeSkewTransforms.set(view, skewX, skewY)
8095
+ // Summed and multiplied first, and bounded once: a view throws on a
8096
+ // scale or an offset that is infinite or not a number, and two finite
8097
+ // factors can make either.
8098
+ view.translationX = craftNativeLayoutNumber(translationX)
8099
+ view.translationY = craftNativeLayoutNumber(translationY)
8100
+ view.scaleX = craftNativeLayoutNumber(scaleX)
8101
+ view.scaleY = craftNativeLayoutNumber(scaleY)
8102
+ view.rotation = craftNativeLayoutNumber(rotation)
8103
+ view.rotationX = craftNativeLayoutNumber(rotationX)
8104
+ view.rotationY = craftNativeLayoutNumber(rotationY)
8105
+ CraftNativeSkewTransforms.clear(view)
8106
+ if (transforms != null) CraftNativeSkewTransforms.set(view, craftNativeLayoutNumber(skewX), craftNativeLayoutNumber(skewY))
7998
8107
  }
7999
8108
 
8000
8109
  private fun skewFactor(value: Any?): Float =
package/dist/cli.js CHANGED
@@ -3266,7 +3266,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3266
3266
  `);
3267
3267
  }
3268
3268
  // package.json
3269
- var version = "0.0.500";
3269
+ var version = "0.0.503";
3270
3270
 
3271
3271
  // bin/cli.ts
3272
3272
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.500";
379
+ var version = "0.0.503";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.500";
120
+ var version = "0.0.503";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -214,31 +214,34 @@ private func craftNativePickerValue(_ value: Any?) -> String? {
214
214
  return nil
215
215
  }
216
216
 
217
- private func craftNativeGridColumnCount(_ value: Any?) -> Int {
218
- if let number = craftNativeNumber(value) { return max(1, number.intValue) }
219
- guard let text = value as? String else { return 1 }
220
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
221
- if trimmed.hasPrefix("repeat(") {
222
- let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
223
- if let count = Int(digits) { return max(1, count) }
217
+ /// The most tracks a grid is given, either way. A count is whatever the
218
+ /// script said (`gridTemplateRows: Number.MAX_VALUE`,
219
+ /// `repeat(4294967296, 1fr)`), and the layout makes an array of rows that
220
+ /// long and hands the shared engine both counts as a `UInt32`: the first ran
221
+ /// the process out of memory and the second trapped. No screen has room to
222
+ /// show more tracks than this.
223
+ let craftNativeGridTrackLimit = 1024
224
+
225
+ func craftNativeGridTrackCount(_ value: Any?) -> Int? {
226
+ if let number = craftNativeNumber(value) {
227
+ return min(craftNativeGridTrackLimit, max(1, craftNativeInt(number.doubleValue)))
224
228
  }
225
- let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
226
- return max(1, tracks.count)
227
- }
228
-
229
- private func craftNativeGridRowCount(_ value: Any?) -> Int? {
230
- if let number = craftNativeNumber(value) { return max(1, number.intValue) }
231
229
  guard let text = value as? String else { return nil }
232
230
  let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
233
231
  guard !trimmed.isEmpty else { return nil }
234
232
  if trimmed.hasPrefix("repeat(") {
235
- let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
236
- if let count = Int(digits) { return max(1, count) }
233
+ let digits = trimmed.dropFirst("repeat(".count).prefix { ("0"..."9").contains($0) }
234
+ // Digits past what an Int holds are still a count, and still too many.
235
+ if !digits.isEmpty { return min(craftNativeGridTrackLimit, max(1, Int(digits) ?? craftNativeGridTrackLimit)) }
237
236
  }
238
237
  let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
239
- return tracks.isEmpty ? nil : max(1, tracks.count)
238
+ return tracks.isEmpty ? nil : min(craftNativeGridTrackLimit, tracks.count)
240
239
  }
241
240
 
241
+ private func craftNativeGridColumnCount(_ value: Any?) -> Int { craftNativeGridTrackCount(value) ?? 1 }
242
+
243
+ private func craftNativeGridRowCount(_ value: Any?) -> Int? { craftNativeGridTrackCount(value) }
244
+
242
245
  /// Optional bridge to the shared Zig flex rule. Native hosts retain their
243
246
  /// local implementation when no Craft runtime is linked (the normal SDK
244
247
  /// fallback), while runtime-backed apps get one cross-platform calculation.
@@ -4376,7 +4379,11 @@ class CraftNativePositionedImageView: UIView {
4376
4379
  }
4377
4380
  let rawFit = value("objectFit"), rawPosition = value("objectPosition")
4378
4381
  if rawFit != nil || rawPosition != nil {
4379
- let requested = rawFit as? String ?? "fill"
4382
+ // A position does not choose a fit, as in CSS. STX compiles
4383
+ // `object-cover` to `resizeMode: "cover"` and sets no objectFit,
4384
+ // so a cover image given a position was drawn as "fill":
4385
+ // stretched to its box instead of cropped (#532).
4386
+ let requested = rawFit as? String ?? Self.objectFit(resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String)
4380
4387
  fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
4381
4388
  position = Self.objectPosition(rawPosition)
4382
4389
  } else { fit = nil }
@@ -4404,6 +4411,17 @@ class CraftNativePositionedImageView: UIView {
4404
4411
  var usesObjectGeometry: Bool { fit != nil }
4405
4412
  var objectCovers: Bool { fit == "cover" }
4406
4413
 
4414
+ /// The fit a `resizeMode` already asks for, for an image that has a
4415
+ /// position and no `objectFit`; "fill" when it has neither.
4416
+ static func objectFit(resizeMode: String?) -> String {
4417
+ switch resizeMode {
4418
+ case "cover": return "cover"
4419
+ case "contain": return "contain"
4420
+ case "center": return "none"
4421
+ default: return "fill"
4422
+ }
4423
+ }
4424
+
4407
4425
  private static func objectPosition(_ raw: Any?) -> CGPoint {
4408
4426
  guard let text = raw as? String else { return CGPoint(x: 0.5, y: 0.5) }
4409
4427
  var parts = text.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
@@ -7184,6 +7202,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7184
7202
  // a screen that hid its own.
7185
7203
  navigationController?.setNavigationBarHidden(false, animated: animated)
7186
7204
  }
7205
+ // Options can arrive before the screen is in a stack: whether it has
7206
+ // a screen to go back to is known now.
7207
+ if backButtonSpec != nil { applyLeadingBarItems() }
7187
7208
  applyNavigationBarAppearance()
7188
7209
  // Each screen says whether it wants the tab bar, so one that hid it
7189
7210
  // never leaves it hidden for the next. Only the screen on screen:
@@ -7858,7 +7879,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7858
7879
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7859
7880
  /// `craft.secureStorage.set` wrote.
7860
7881
  /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7861
- /// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
7882
+ /// backTitle, backButton, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
7862
7883
  /// contentSwipeBack,
7863
7884
  /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7864
7885
  /// rightButtons, tabAccessory })`; a button's tap is the event
@@ -8474,10 +8495,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8474
8495
  navigationController?.setNavigationBarHidden(hidden, animated: animatesNavigationBarChanges)
8475
8496
  }
8476
8497
  }
8498
+ if options.keys.contains("backButton") {
8499
+ backButtonSpec = options["backButton"] as? [String: Any]
8500
+ backButtonItem = nil
8501
+ }
8477
8502
  if options.keys.contains("leftButtons") {
8478
8503
  let buttons = (options["leftButtons"] as? [[String: Any]]) ?? []
8479
- navigationItem.leftItemsSupplementBackButton = true
8480
- navigationItem.leftBarButtonItems = buttons.compactMap(navigationButton)
8504
+ leftButtonItems = buttons.compactMap(navigationButton)
8505
+ }
8506
+ if options.keys.contains("backButton") || options.keys.contains("leftButtons") {
8507
+ applyLeadingBarItems()
8481
8508
  }
8482
8509
  if options.keys.contains("rightButtons") {
8483
8510
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
@@ -8575,6 +8602,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8575
8602
  return actions.isEmpty ? nil : UIMenu(children: actions)
8576
8603
  }
8577
8604
 
8605
+ /// `backButton`: what this screen's Back is drawn with, when it is not
8606
+ /// the chevron (`{ symbol: "xmark", accessibilityLabel: "Close" }` for a
8607
+ /// screen that closes rather than goes back). `leftButtons` are added
8608
+ /// beside Back, so a screen could not put its own symbol in Back's place
8609
+ /// (#527). Nil is the system's chevron.
8610
+ private var backButtonSpec: [String: Any]?
8611
+ private var backButtonItem: UIBarButtonItem?
8612
+ private var leftButtonItems: [UIBarButtonItem] = []
8613
+
8614
+ /// The screen's own Back, when it asked for one and has a screen to go
8615
+ /// back to, then its left buttons. The chevron is hidden only while the
8616
+ /// item that replaces it is in the bar: a symbol UIKit does not have
8617
+ /// leaves the chevron. The back-swipe is the shell's to allow, and is
8618
+ /// not affected.
8619
+ private func applyLeadingBarItems() {
8620
+ // Known to be the first screen of its stack: nothing to go back to.
8621
+ let isRoot = navigationController?.viewControllers.first === self
8622
+ if !isRoot, backButtonItem == nil, let spec = backButtonSpec,
8623
+ let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
8624
+ let item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(backButtonTapped))
8625
+ item.accessibilityIdentifier = "nav-back"
8626
+ item.accessibilityLabel = (spec["accessibilityLabel"] as? String).flatMap { $0.isEmpty ? nil : $0 }
8627
+ ?? (symbol.hasPrefix("xmark") ? "Close" : "Back")
8628
+ backButtonItem = item
8629
+ }
8630
+ let back = isRoot ? nil : backButtonItem
8631
+ navigationItem.hidesBackButton = back != nil
8632
+ navigationItem.leftItemsSupplementBackButton = true
8633
+ navigationItem.leftBarButtonItems = (back.map { [$0] } ?? []) + leftButtonItems
8634
+ }
8635
+
8636
+ /// What `craft.navigation.back()` does.
8637
+ @objc private func backButtonTapped() {
8638
+ if !isAppScript, navigationController?.topViewController === self {
8639
+ navigationController?.popViewController(animated: true)
8640
+ }
8641
+ }
8642
+
8578
8643
  private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
8579
8644
  guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
8580
8645
  let action = UIAction { [weak self] _ in
@@ -9060,27 +9125,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9060
9125
  /// view is does not come into it: a page of a pager that is off to one
9061
9126
  /// side now is under the actions once it is swiped to. The safe area and
9062
9127
  /// the viewport are left alone: content still scrolls behind the
9063
- /// actions. Internal for XCTest.
9128
+ /// actions.
9129
+ ///
9130
+ /// Two things are not behind the actions, and get none. A scroll view
9131
+ /// that is not inside the shell the actions float in: a sheet is
9132
+ /// presented in front of them, and its scroll view was given the room of
9133
+ /// a capsule it covers, so every sheet could be dragged up into blank
9134
+ /// space. And a scroll view or list inside another that scrolls the same
9135
+ /// way: it is brought clear by the outer one, and where it sits in a page
9136
+ /// not yet scrolled to it says nothing about the glass; a short list
9137
+ /// below the fold was given the height of the page above it. Internal
9138
+ /// for XCTest.
9064
9139
  func insetScrollViewsForFloatingActions() {
9065
9140
  let chrome = CraftChrome.shared
9066
- let actions = chrome.floatingActions.flatMap { frame in
9067
- chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? frame : nil }
9068
- }
9141
+ let host = chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? $0 : nil }
9142
+ let actions = host == nil ? nil : chrome.floatingActions
9069
9143
  func cover(_ scroll: UIScrollView) -> CGFloat {
9070
- guard let actions, let window = scroll.window else { return 0 }
9144
+ guard let actions, let host, let window = scroll.window, scroll.isDescendant(of: host) else { return 0 }
9071
9145
  let frame = scroll.convert(scroll.bounds, to: window)
9146
+ // Nothing past the end of the shell is on the glass.
9147
+ let end = min(frame.maxY, host.convert(host.bounds, to: window).maxY)
9072
9148
  // An edge-to-edge scroller already clears the tab bar through
9073
9149
  // UIKit's safe area. Add only what the actions reach above it.
9074
9150
  let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
9075
- return max(0, frame.maxY - actions.minY - automaticBottom)
9151
+ return max(0, end - actions.minY - automaticBottom)
9076
9152
  }
9077
- func visit(_ node: RenderedNode?) {
9153
+ func visit(_ node: RenderedNode?, inside: Bool) {
9078
9154
  guard let node else { return }
9079
- if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scroll.floatingCover = cover(scroll) }
9080
- else if let list = node.view as? CraftNativeFlatList { list.floatingCover = cover(list) }
9081
- node.children.forEach(visit)
9155
+ var inside = inside
9156
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical {
9157
+ scroll.floatingCover = inside ? 0 : cover(scroll)
9158
+ inside = true
9159
+ } else if let list = node.view as? CraftNativeFlatList {
9160
+ list.floatingCover = inside ? 0 : cover(list)
9161
+ if (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { inside = true }
9162
+ }
9163
+ node.children.forEach { visit($0, inside: inside) }
9082
9164
  }
9083
- visit(renderedRoot)
9165
+ visit(renderedRoot, inside: false)
9084
9166
  floatingClearanceApplied = actions != nil
9085
9167
  }
9086
9168
 
@@ -11550,10 +11632,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11550
11632
  if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11551
11633
  addTextDecorationAttributes(style, to: &attributes)
11552
11634
  if let shadow = textShadow(style) { attributes[.shadow] = shadow }
11635
+ // A CSS line box may be shorter than its font while glyphs paint outside
11636
+ // it. UIKit clips that same paragraph inside a UILabel. Preserve the
11637
+ // resolved font metrics for layout and drawing, including custom faces
11638
+ // whose ascenders exceed their point size and Dynamic Type scaling.
11553
11639
  if let lineHeight = styleNumber(style["lineHeight"]) {
11554
11640
  let paragraph = NSMutableParagraphStyle()
11555
- paragraph.minimumLineHeight = lineHeight
11556
- paragraph.maximumLineHeight = lineHeight
11641
+ paragraph.minimumLineHeight = max(lineHeight, label.font.lineHeight)
11642
+ paragraph.maximumLineHeight = max(lineHeight, label.font.lineHeight)
11557
11643
  paragraph.alignment = label.textAlignment
11558
11644
  attributes[.paragraphStyle] = paragraph
11559
11645
  }
@@ -11607,8 +11693,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11607
11693
  if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11608
11694
  if let lineHeight = styleNumber(style["lineHeight"]) {
11609
11695
  let paragraph = NSMutableParagraphStyle()
11610
- paragraph.minimumLineHeight = lineHeight
11611
- paragraph.maximumLineHeight = lineHeight
11696
+ paragraph.minimumLineHeight = max(lineHeight, font.lineHeight)
11697
+ paragraph.maximumLineHeight = max(lineHeight, font.lineHeight)
11612
11698
  attributes[.paragraphStyle] = paragraph
11613
11699
  }
11614
11700
  addTextDecorationAttributes(style, to: &attributes)
@@ -11635,8 +11721,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11635
11721
  // then paints a second line outside the field instead of scrolling.
11636
11722
  if singleLine { paragraph.lineBreakMode = .byClipping }
11637
11723
  if let lineHeight = styleNumber(style["lineHeight"]) {
11638
- paragraph.minimumLineHeight = lineHeight
11639
- paragraph.maximumLineHeight = lineHeight
11724
+ paragraph.minimumLineHeight = max(lineHeight, font.lineHeight)
11725
+ paragraph.maximumLineHeight = max(lineHeight, font.lineHeight)
11640
11726
  }
11641
11727
  attributes[.paragraphStyle] = paragraph
11642
11728
  addTextDecorationAttributes(style, to: &attributes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.500",
3
+ "version": "0.0.503",
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>",