@craft-native/ios 0.0.395 → 0.0.408

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.
@@ -1108,6 +1108,10 @@ private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
1108
1108
 
1109
1109
  /// Keep the indicator's intrinsic size separate from authored style transforms.
1110
1110
  private final class CraftNativeActivityIndicatorView: UIActivityIndicatorView {
1111
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1112
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
1113
+ }
1114
+
1111
1115
  var indicatorScale: CGFloat = 1
1112
1116
  }
1113
1117
 
@@ -1122,6 +1126,10 @@ private final class CraftNativeActivityIndicatorView: UIActivityIndicatorView {
1122
1126
  /// `enableBackgroundAudio` writes), and comes back into its place when the
1123
1127
  /// app does.
1124
1128
  private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate {
1129
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1130
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
1131
+ }
1132
+
1125
1133
  private let playerController = AVPlayerViewController()
1126
1134
  private var player: AVPlayer?
1127
1135
  private var sourceURI = ""
@@ -1454,6 +1462,10 @@ private final class CraftMarkerPhotoRedirects: NSObject, URLSessionTaskDelegate
1454
1462
  }
1455
1463
 
1456
1464
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1465
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1466
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
1467
+ }
1468
+
1457
1469
  private var annotationsByID: [String: MKPointAnnotation] = [:]
1458
1470
  private var overlaysByID: [String: MKPolyline] = [:]
1459
1471
  private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
@@ -2394,6 +2406,46 @@ private final class CraftNativeLayoutStyleBox {
2394
2406
  init(_ style: CraftNativeLayoutStyle) { self.style = style }
2395
2407
  }
2396
2408
 
2409
+ /// Pointer policy applies to authored nodes, not UIKit's private control views.
2410
+ enum CraftNativePointerEvents: String {
2411
+ case auto, none
2412
+ case boxNone = "box-none"
2413
+ case boxOnly = "box-only"
2414
+
2415
+ static func resolve(props: [String: Any], style: [String: Any]) -> Self {
2416
+ let prop = props["pointerEvents"]
2417
+ let value = prop == nil || prop is NSNull ? style["pointerEvents"] : prop
2418
+ return (value as? String).flatMap(Self.init(rawValue:)) ?? .auto
2419
+ }
2420
+ }
2421
+
2422
+ private var craftNativePointerEventsKey: UInt8 = 0
2423
+
2424
+ extension UIView {
2425
+ // nil identifies an internal view; even authored `auto` nodes are marked.
2426
+ var craftPointerEvents: CraftNativePointerEvents? {
2427
+ get { (objc_getAssociatedObject(self, &craftNativePointerEventsKey) as? String).flatMap(CraftNativePointerEvents.init(rawValue:)) }
2428
+ set { objc_setAssociatedObject(self, &craftNativePointerEventsKey, newValue?.rawValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) }
2429
+ }
2430
+
2431
+ func craftPointerHitTest(_ point: CGPoint, with event: UIEvent?, native: () -> UIView?) -> UIView? {
2432
+ guard let policy = craftPointerEvents, policy != .auto else { return native() }
2433
+ guard policy != .none, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
2434
+ if policy == .boxOnly && !self.point(inside: point, with: event) { return nil }
2435
+ let hit = native()
2436
+ var candidate = hit
2437
+ var authoredChild = false
2438
+ while let view = candidate, view !== self {
2439
+ if view.craftPointerEvents != nil { authoredChild = true; break }
2440
+ candidate = view.superview
2441
+ }
2442
+ if policy == .boxNone { return authoredChild ? hit : nil }
2443
+ // Preserve private controls (picker wheels, text selection, map gestures),
2444
+ // while excluding authored descendants of a box-only container.
2445
+ return authoredChild ? self : hit ?? self
2446
+ }
2447
+ }
2448
+
2397
2449
  private var craftNativeLayoutStyleKey: UInt8 = 0
2398
2450
  private var craftNativeCornerRadiusKey: UInt8 = 0
2399
2451
  private var craftNativeCornerRadiiKey: UInt8 = 0
@@ -3021,12 +3073,14 @@ private class CraftNativeFlowView: UIStackView {
3021
3073
  /// is inside the view, so a floating button absolutely positioned out of a
3022
3074
  /// zero-height parent (a component's wrapper) drew but could not be tapped.
3023
3075
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
3024
- if let hit = super.hitTest(point, with: event) { return hit }
3025
- guard !clipsToBounds, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
3026
- for child in subviews.reversed() where !child.isHidden && child.alpha > 0.01 && child.isUserInteractionEnabled {
3027
- if let hit = child.hitTest(child.convert(point, from: self), with: event) { return hit }
3076
+ return craftPointerHitTest(point, with: event) {
3077
+ if let hit = super.hitTest(point, with: event) { return hit }
3078
+ guard !clipsToBounds, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
3079
+ for child in subviews.reversed() where !child.isHidden && child.alpha > 0.01 && child.isUserInteractionEnabled {
3080
+ if let hit = child.hitTest(child.convert(point, from: self), with: event) { return hit }
3081
+ }
3082
+ return nil
3028
3083
  }
3029
- return nil
3030
3084
  }
3031
3085
 
3032
3086
  // MARK: Children
@@ -4100,10 +4154,107 @@ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
4100
4154
  }
4101
4155
  }
4102
4156
 
4157
+ // Preserve source dimensions when the loader downsamples decoded pixels. CSS
4158
+ // scale-down/none use intrinsic dimensions, never the thumbnail's display size.
4159
+ private var craftImageIntrinsicKey: UInt8 = 0
4160
+ private extension UIImage {
4161
+ var craftIntrinsicSize: CGSize {
4162
+ get { (objc_getAssociatedObject(self, &craftImageIntrinsicKey) as? NSValue)?.cgSizeValue ?? size }
4163
+ set { objc_setAssociatedObject(self, &craftImageIntrinsicKey, NSValue(cgSize: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
4164
+ }
4165
+ }
4166
+
4167
+ /// Explicit CSS-facing image geometry; the loader and semantic view stay retained.
4168
+ class CraftNativePositionedImageView: UIImageView {
4169
+ private var storedImage: UIImage?
4170
+ private var fittedImage: UIImageView?
4171
+ private var fit: String?
4172
+ private var position = CGPoint(x: 0.5, y: 0.5)
4173
+
4174
+ override var image: UIImage? {
4175
+ get { storedImage }
4176
+ set {
4177
+ storedImage = newValue
4178
+ super.image = fit == nil ? newValue : nil
4179
+ fittedImage?.image = newValue
4180
+ invalidateIntrinsicContentSize()
4181
+ setNeedsLayout()
4182
+ }
4183
+ }
4184
+
4185
+ override var intrinsicContentSize: CGSize {
4186
+ fit == nil ? super.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
4187
+ }
4188
+
4189
+ func configureObjectGeometry(props: [String: Any], style: [String: Any]) {
4190
+ func value(_ name: String) -> Any? {
4191
+ if let raw = props[name], !(raw is NSNull) { return raw }
4192
+ if let raw = style[name], !(raw is NSNull) { return raw }
4193
+ return nil
4194
+ }
4195
+ let rawFit = value("objectFit"), rawPosition = value("objectPosition")
4196
+ if rawFit != nil || rawPosition != nil {
4197
+ let requested = rawFit as? String ?? "fill"
4198
+ fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
4199
+ position = Self.objectPosition(rawPosition)
4200
+ if fittedImage == nil {
4201
+ let child = UIImageView()
4202
+ child.isUserInteractionEnabled = false
4203
+ child.isAccessibilityElement = false
4204
+ child.contentMode = .scaleToFill
4205
+ insertSubview(child, at: 0)
4206
+ fittedImage = child
4207
+ }
4208
+ } else { fit = nil }
4209
+ fittedImage?.isHidden = fit == nil
4210
+ super.image = fit == nil ? storedImage : nil
4211
+ fittedImage?.image = storedImage
4212
+ invalidateIntrinsicContentSize()
4213
+ setNeedsLayout()
4214
+ }
4215
+
4216
+ var usesObjectGeometry: Bool { fit != nil }
4217
+ var objectCovers: Bool { fit == "cover" }
4218
+
4219
+ private static func objectPosition(_ raw: Any?) -> CGPoint {
4220
+ guard let text = raw as? String else { return CGPoint(x: 0.5, y: 0.5) }
4221
+ var parts = text.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
4222
+ guard (1...2).contains(parts.count) else { return CGPoint(x: 0.5, y: 0.5) }
4223
+ if parts.count == 1 {
4224
+ parts = ["top", "bottom"].contains(parts[0]) ? ["center", parts[0]] : [parts[0], "center"]
4225
+ } else if ["top", "bottom"].contains(parts[0]) || ["left", "right"].contains(parts[1]) { parts.swapAt(0, 1) }
4226
+ func fraction(_ part: String, low: String, high: String) -> CGFloat? {
4227
+ if part == low { return 0 }; if part == high { return 1 }; if part == "center" { return 0.5 }
4228
+ guard part.hasSuffix("%"), let number = Double(part.dropLast()), number.isFinite else { return nil }
4229
+ return CGFloat(number / 100)
4230
+ }
4231
+ guard let x = fraction(parts[0], low: "left", high: "right"), let y = fraction(parts[1], low: "top", high: "bottom") else { return CGPoint(x: 0.5, y: 0.5) }
4232
+ return CGPoint(x: x, y: y)
4233
+ }
4234
+
4235
+ override func layoutSubviews() {
4236
+ super.layoutSubviews()
4237
+ guard let fit, let child = fittedImage else { return }
4238
+ let source = storedImage?.craftIntrinsicSize ?? .zero
4239
+ guard source.width > 0, source.height > 0 else { child.frame = .zero; return }
4240
+ let contain = min(bounds.width / source.width, bounds.height / source.height)
4241
+ let scale: CGFloat = fit == "cover" ? max(bounds.width / source.width, bounds.height / source.height)
4242
+ : fit == "none" ? 1 : fit == "scale-down" ? min(1, contain) : contain
4243
+ let size = fit == "fill" ? bounds.size : CGSize(width: source.width * scale, height: source.height * scale)
4244
+ child.frame = CGRect(x: bounds.minX + (bounds.width - size.width) * position.x,
4245
+ y: bounds.minY + (bounds.height - size.height) * position.y, width: size.width, height: size.height)
4246
+ child.tintColor = tintColor
4247
+ }
4248
+ }
4249
+
4103
4250
  /// An image-backed flex container. The image stays behind ordinary native
4104
4251
  /// children so `ImageBackground` keeps the same layout and interaction
4105
4252
  /// semantics as a `View` while using the shared image loader.
4106
- private final class CraftNativeImageBackgroundView: UIImageView {
4253
+ private final class CraftNativeImageBackgroundView: CraftNativePositionedImageView {
4254
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4255
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
4256
+ }
4257
+
4107
4258
  let contentStack = CraftNativeFlowView()
4108
4259
 
4109
4260
  override init(frame: CGRect) {
@@ -4142,6 +4293,10 @@ private final class CraftNativeSegmentElement: UIAccessibilityElement {
4142
4293
  }
4143
4294
 
4144
4295
  private final class CraftNativeSegmentedControl: UISegmentedControl {
4296
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4297
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
4298
+ }
4299
+
4145
4300
  var values: [String] = []
4146
4301
  private var segments: [CraftNativeSegmentElement] = []
4147
4302
  override var accessibilityElements: [Any]? {
@@ -4391,28 +4546,30 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4391
4546
  }
4392
4547
 
4393
4548
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4394
- guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
4395
- let contentPoint = contentStack.convert(point, from: self)
4396
- if contentStack.bounds.contains(contentPoint), let hit = contentStack.hitTest(contentPoint, with: event), hit !== contentStack {
4397
- // Flow/layout views occupy the modal's entire content rect, but a
4398
- // blank part of one must still be a scrim tap. Preserve touches
4399
- // only when the hit view (or one of its ancestors) actually owns
4400
- // a control or gesture recognizer.
4401
- var candidate: UIView? = hit
4402
- while let view = candidate, view !== contentStack {
4403
- if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return hit }
4404
- candidate = view.superview
4405
- }
4406
- }
4407
- if !blocksBackground {
4408
- // A dismissible popup consumes its outside hit so the close event
4409
- // cannot also activate the screen below. Otherwise it owns the
4410
- // hit only when a blocking modal is underneath it; with no modal
4411
- // below, the screen remains pass-through.
4412
- if dismissOnOutsideTap { return self }
4413
- return craftScreenController?.hasBlockingModalBelow(self) == true ? self : nil
4414
- }
4415
- return blocker.hitTest(blocker.convert(point, from: self), with: event)
4549
+ return craftPointerHitTest(point, with: event) {
4550
+ guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
4551
+ let contentPoint = contentStack.convert(point, from: self)
4552
+ if contentStack.bounds.contains(contentPoint), let hit = contentStack.hitTest(contentPoint, with: event), hit !== contentStack {
4553
+ // Flow/layout views occupy the modal's entire content rect, but a
4554
+ // blank part of one must still be a scrim tap. Preserve touches
4555
+ // only when the hit view (or one of its ancestors) actually owns
4556
+ // a control or gesture recognizer.
4557
+ var candidate: UIView? = hit
4558
+ while let view = candidate, view !== contentStack {
4559
+ if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return hit }
4560
+ candidate = view.superview
4561
+ }
4562
+ }
4563
+ if !blocksBackground {
4564
+ // A dismissible popup consumes its outside hit so the close event
4565
+ // cannot also activate the screen below. Otherwise it owns the
4566
+ // hit only when a blocking modal is underneath it; with no modal
4567
+ // below, the screen remains pass-through.
4568
+ if dismissOnOutsideTap { return self }
4569
+ return craftScreenController?.hasBlockingModalBelow(self) == true ? self : nil
4570
+ }
4571
+ return blocker.hitTest(blocker.convert(point, from: self), with: event)
4572
+ }
4416
4573
  }
4417
4574
 
4418
4575
  override func layoutSubviews() {
@@ -4513,6 +4670,10 @@ func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: Str
4513
4670
  }
4514
4671
 
4515
4672
  private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
4673
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4674
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
4675
+ }
4676
+
4516
4677
  let contentStack = CraftNativeFlowView()
4517
4678
  private let keyboardTapGesture = UITapGestureRecognizer()
4518
4679
  private var keyboardShouldPersistTaps = "never"
@@ -4877,6 +5038,10 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4877
5038
  /// inherited down the tree as SVG does; `dark:` classes arrive already
4878
5039
  /// resolved for the appearance, so a redraw is all a theme change needs.
4879
5040
  final class CraftNativeSvgView: UIView {
5041
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5042
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5043
+ }
5044
+
4880
5045
  var drawing: [String: Any] = [:] {
4881
5046
  didSet {
4882
5047
  animates = Self.hasAnimation(drawing["children"] as? [Any] ?? [])
@@ -5282,6 +5447,10 @@ private final class CraftNativeGradientLayer: CAGradientLayer {
5282
5447
  /// pill with its label touching the edges. The padding is added to its size
5283
5448
  /// and the label laid out inside it, at the alignment the style asked for.
5284
5449
  private final class CraftNativeButton: UIButton {
5450
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5451
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5452
+ }
5453
+
5285
5454
  var padding: UIEdgeInsets = .zero {
5286
5455
  didSet {
5287
5456
  guard padding != oldValue else { return }
@@ -5323,6 +5492,10 @@ private final class CraftNativeButton: UIButton {
5323
5492
  /// Opt-in solid switch. Draws only public UIControl content; it neither walks
5324
5493
  /// UISwitch's private views nor changes UIKit animations elsewhere in the app.
5325
5494
  final class CraftNativeSolidSwitch: UIControl {
5495
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5496
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5497
+ }
5498
+
5326
5499
  private(set) var isOn = false
5327
5500
  var onColor: UIColor? { didSet { setNeedsDisplay() } }
5328
5501
  var offColor: UIColor? { didSet { setNeedsDisplay() } }
@@ -5433,10 +5606,18 @@ final class CraftNativeSolidSwitch: UIControl {
5433
5606
  /// square/checkmark symbols while keeping the control's title and accessibility
5434
5607
  /// state native and keyboard/touch reachable.
5435
5608
  private final class CraftNativeCheckboxButton: UIButton {
5609
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5610
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5611
+ }
5612
+
5436
5613
  var indeterminate = false
5437
5614
  }
5438
5615
 
5439
5616
  private final class CraftNativeLabel: UILabel {
5617
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5618
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5619
+ }
5620
+
5440
5621
  var insets = UIEdgeInsets.zero {
5441
5622
  didSet {
5442
5623
  guard insets != oldValue else { return }
@@ -5470,6 +5651,10 @@ private final class CraftNativeLabel: UILabel {
5470
5651
  /// selection. UILabel has no selection model; UITextView supplies UIKit's
5471
5652
  /// standard loupe, menu and copy behavior while remaining a plain text leaf.
5472
5653
  private final class CraftNativeSelectableTextView: UITextView {
5654
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5655
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5656
+ }
5657
+
5473
5658
  convenience init() {
5474
5659
  self.init(frame: .zero, textContainer: nil)
5475
5660
  }
@@ -5498,6 +5683,10 @@ private final class CraftNativeSelectableTextView: UITextView {
5498
5683
  /// A one-line TextInput. Its style's padding is where the text and the
5499
5684
  /// placeholder start, as in a web input; UITextField has no padding of its own.
5500
5685
  final class CraftNativeTextField: UITextField {
5686
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5687
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5688
+ }
5689
+
5501
5690
  var padding = UIEdgeInsets.zero {
5502
5691
  didSet {
5503
5692
  guard padding != oldValue else { return }
@@ -5529,6 +5718,10 @@ private final class CraftNativeFlexSpacer: UIView {}
5529
5718
  /// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
5530
5719
  /// when the style gives it no size at all.
5531
5720
  private final class CraftNativeIconView: UIImageView {
5721
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5722
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5723
+ }
5724
+
5532
5725
  var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
5533
5726
 
5534
5727
  /// An icon says only what the screen labels it. iOS gives a symbol image
@@ -5554,6 +5747,10 @@ private final class CraftNativeIconView: UIImageView {
5554
5747
  }
5555
5748
 
5556
5749
  private final class CraftNativeTextView: UITextView {
5750
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5751
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5752
+ }
5753
+
5557
5754
  private let placeholderLabel = UILabel()
5558
5755
  private var nativeTextContainerInset: UIEdgeInsets?
5559
5756
  private var nativeLineFragmentPadding: CGFloat?
@@ -5869,6 +6066,41 @@ enum CraftNativeBundle {
5869
6066
  }
5870
6067
  }
5871
6068
 
6069
+ private final class CraftNativePickerView: UIPickerView {
6070
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6071
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6072
+ }
6073
+
6074
+ }
6075
+
6076
+ private final class CraftNativeDatePickerView: UIDatePicker {
6077
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6078
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6079
+ }
6080
+
6081
+ }
6082
+
6083
+ private final class CraftNativeSwitch: UISwitch {
6084
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6085
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6086
+ }
6087
+
6088
+ }
6089
+
6090
+ private final class CraftNativeSlider: UISlider {
6091
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6092
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6093
+ }
6094
+
6095
+ }
6096
+
6097
+ private final class CraftNativeImageView: CraftNativePositionedImageView {
6098
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6099
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6100
+ }
6101
+
6102
+ }
6103
+
5872
6104
  private final class CraftNativeScreenRootView: UIView {
5873
6105
  weak var controller: CraftNativeScreenController?
5874
6106
 
@@ -5879,7 +6111,8 @@ private final class CraftNativeScreenRootView: UIView {
5879
6111
  // overlays remain pass-through.
5880
6112
  if let controller {
5881
6113
  for modal in controller.view.subviews.compactMap({ $0 as? CraftNativeModalView }).reversed()
5882
- where !modal.blocksBackground && !modal.isHidden && modal.alpha > 0.01 {
6114
+ where !modal.blocksBackground && !modal.isHidden && modal.alpha > 0.01
6115
+ && modal.craftPointerEvents != CraftNativePointerEvents.none && modal.craftPointerEvents != .boxNone {
5883
6116
  let local = modal.convert(point, from: self)
5884
6117
  if !modal.bounds.contains(local) && (modal.dismissOnOutsideTap || controller.hasBlockingModalBelow(modal)) { return modal }
5885
6118
  }
@@ -6371,7 +6604,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6371
6604
  appliedTraitDefaults = traits
6372
6605
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
6373
6606
  applyNavigationBarAppearance()
6374
- if let document = mutationDocument.node("root") {
6607
+ if let document = mutationDocument.document {
6375
6608
  renderCommitted(document)
6376
6609
  } else {
6377
6610
  rootStack.setNeedsLayout()
@@ -6469,6 +6702,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6469
6702
  return view.subviews[..<index].contains { candidate in
6470
6703
  guard let other = candidate as? CraftNativeModalView else { return false }
6471
6704
  return other.blocksBackground && !other.isHidden && other.alpha > 0.01
6705
+ && other.craftPointerEvents != CraftNativePointerEvents.none && other.craftPointerEvents != .boxNone
6472
6706
  }
6473
6707
  }
6474
6708
 
@@ -7395,6 +7629,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7395
7629
  appearance.configureWithOpaqueBackground()
7396
7630
  appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
7397
7631
  appearance.shadowColor = .clear
7632
+ CraftChromeFonts.apply(to: appearance)
7398
7633
  navigationItem.standardAppearance = appearance
7399
7634
  navigationItem.scrollEdgeAppearance = appearance
7400
7635
  navigationItem.compactAppearance = appearance
@@ -7674,6 +7909,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7674
7909
  #if compiler(>=6.2)
7675
7910
  if #available(iOS 26.0, *) {
7676
7911
  trackedScrollView?.topEdgeEffect.isHidden = false
7912
+ trackedScrollView?.topEdgeEffect.style = .automatic
7677
7913
  trackedScrollView?.bottomEdgeEffect.isHidden = false
7678
7914
  }
7679
7915
  #endif
@@ -7684,10 +7920,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7684
7920
  }
7685
7921
  #if compiler(>=6.2)
7686
7922
  if #available(iOS 26.0, *) {
7687
- // Floating button groups carry their own material. UIKit's
7688
- // separate scroll-edge wash would cover the content behind
7689
- // and beyond them even with a transparent bar appearance.
7690
- scroll?.topEdgeEffect.isHidden = topEdgeToEdge && !navigationBarSolid
7923
+ // The clock and navigation title need separation from live
7924
+ // content. Keep UIKit's soft top scrim rather than removing
7925
+ // it or adding an opaque bar. The floating tab pill already
7926
+ // carries its own material, so its full-width wash stays off.
7927
+ scroll?.topEdgeEffect.isHidden = false
7928
+ scroll?.topEdgeEffect.style = topEdgeToEdge && !navigationBarSolid ? .soft : .automatic
7691
7929
  scroll?.bottomEdgeEffect.isHidden = tabbedScroll
7692
7930
  }
7693
7931
  #endif
@@ -8332,7 +8570,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8332
8570
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
8333
8571
  func render(_ document: [String: Any]) {
8334
8572
  mutationDocument.replace(with: document)
8335
- renderCommitted(document)
8573
+ // Import assigns protocol IDs to legacy descendants. Draw that same
8574
+ // committed tree from the first frame, so later patches cannot turn
8575
+ // selector/positional identities into different native controls.
8576
+ guard let committed = mutationDocument.document else { return }
8577
+ renderCommitted(committed)
8336
8578
  if !hybridRendered {
8337
8579
  hybridRendered = true
8338
8580
  // The app script's drawing is never on screen: not a first frame.
@@ -8973,6 +9215,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8973
9215
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
8974
9216
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
8975
9217
  image.contentMode = imageContentMode(requestedResizeMode)
9218
+ (image as? CraftNativePositionedImageView)?.configureObjectGeometry(props: props, style: style)
8976
9219
  applyImageClipping(image, contentMode: image.contentMode, style: style)
8977
9220
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
8978
9221
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
@@ -8992,6 +9235,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8992
9235
  if let current = background.image { background.image = imageForDisplay(current, view: background) }
8993
9236
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
8994
9237
  background.contentMode = imageContentMode(requestedResizeMode)
9238
+ background.configureObjectGeometry(props: props, style: style)
8995
9239
  applyImageClipping(background, contentMode: background.contentMode, style: style)
8996
9240
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
8997
9241
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
@@ -9133,6 +9377,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9133
9377
  )
9134
9378
  if disabled { result.isUserInteractionEnabled = false }
9135
9379
  }
9380
+ result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9136
9381
  applyAccessibility(props, type: type, to: result)
9137
9382
  (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
9138
9383
  return current
@@ -9179,12 +9424,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9179
9424
  control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
9180
9425
  return control
9181
9426
  case "Picker":
9182
- let picker = UIPickerView()
9427
+ let picker = CraftNativePickerView()
9183
9428
  picker.dataSource = self
9184
9429
  picker.delegate = self
9185
9430
  return picker
9186
9431
  case "DatePicker":
9187
- let picker = UIDatePicker()
9432
+ let picker = CraftNativeDatePickerView()
9188
9433
  picker.preferredDatePickerStyle = .compact
9189
9434
  picker.addTarget(self, action: #selector(datePicked(_:)), for: .valueChanged)
9190
9435
  return picker
@@ -9196,11 +9441,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9196
9441
  toggle.addTarget(self, action: #selector(solidSwitchChanged(_:)), for: .valueChanged)
9197
9442
  return toggle
9198
9443
  }
9199
- let toggle = UISwitch()
9444
+ let toggle = CraftNativeSwitch()
9200
9445
  toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
9201
9446
  return toggle
9202
9447
  case "Slider":
9203
- let slider = UISlider()
9448
+ let slider = CraftNativeSlider()
9204
9449
  slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
9205
9450
  slider.addTarget(self, action: #selector(sliderFinished(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
9206
9451
  return slider
@@ -9209,7 +9454,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9209
9454
  case "StatusBar", "RefreshControl":
9210
9455
  return CraftNativeFlowView()
9211
9456
  case "Image":
9212
- return UIImageView()
9457
+ return CraftNativeImageView()
9213
9458
  case "ImageBackground":
9214
9459
  return CraftNativeImageBackgroundView(frame: .zero)
9215
9460
  case "KeyboardAvoidingView":
@@ -10462,7 +10707,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10462
10707
  if let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) {
10463
10708
  // At the screen's scale, so its size in points is the
10464
10709
  // size it is drawn at.
10465
- return UIImage(cgImage: image, scale: target.scale, orientation: .up)
10710
+ let decoded = UIImage(cgImage: image, scale: target.scale, orientation: .up)
10711
+ decoded.craftIntrinsicSize = CGSize(width: width, height: height)
10712
+ return decoded
10466
10713
  }
10467
10714
  }
10468
10715
  }
@@ -10499,7 +10746,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10499
10746
  let authored = (style["overflow"] as? String) == "hidden"
10500
10747
  || (view.craftRequestedCornerRadius ?? 0) > 0
10501
10748
  || view.craftRequestedCornerRadii != nil
10502
- let clips = authored || contentMode == .scaleAspectFill
10749
+ let clips = authored || contentMode == .scaleAspectFill || (view as? CraftNativePositionedImageView)?.usesObjectGeometry == true
10503
10750
  view.clipsToBounds = clips
10504
10751
  view.layer.masksToBounds = clips
10505
10752
  }
@@ -10546,7 +10793,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10546
10793
  if let url = imageURL(for: uri) {
10547
10794
  // Decoded at the size it is drawn, when the style fixes that size.
10548
10795
  let pixels = Self.displayPixels(of: view)
10549
- let fill = view.contentMode == .scaleAspectFill
10796
+ let fill = (view as? CraftNativePositionedImageView)?.objectCovers == true || view.contentMode == .scaleAspectFill
10550
10797
  // media.keep owns private/offline copies keyed by the authored URL.
10551
10798
  // Read before the decoded/network cache, without retaining private
10552
10799
  // pixels there after the app clears its media on sign-out.
@@ -10654,7 +10901,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10654
10901
  }
10655
10902
 
10656
10903
  private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
10657
- image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
10904
+ let displayed = image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
10905
+ displayed.craftIntrinsicSize = image.craftIntrinsicSize
10906
+ return displayed
10658
10907
  }
10659
10908
 
10660
10909
  private func emitImageEvent(