@craft-native/ios 0.0.304 → 0.0.319

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.
package/README.md CHANGED
@@ -1324,3 +1324,21 @@ and detents; Android sizes its native bottom panel to the selected detent
1324
1324
  without an extra dependency. Omitting presentationStyle keeps the existing
1325
1325
  full-screen overlay. A native sheet's close request still belongs to its
1326
1326
  owner, which sets visible to false.
1327
+
1328
+
1329
+ ### Controlled native paging and font families
1330
+
1331
+ A horizontal `ScrollView` with `pagingEnabled: true` and a numeric `pageIndex`
1332
+ uses viewport-sized direct children. The host advertises `nativePager` and emits
1333
+ `onPageSelected` with `{ index, count }` after initial layout, count changes and
1334
+ settled user selection. Updates to `pageIndex` select a page; removed pages clamp
1335
+ the index and resizing preserves it. Offscreen pages stay mounted but are hidden
1336
+ from native accessibility. `scrollEnabled: false` locks swiping while retaining
1337
+ controls in the selected page. Without `pageIndex`, scroll content keeps its
1338
+ ordinary intrinsic layout; this is an opt-in contract for a shared Pager.
1339
+
1340
+ Text `fontFamily` accepts a CSS fallback list. Bundled/custom fonts take priority
1341
+ when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
1342
+ `sans-serif`/`ui-sans-serif`/`system-ui` select platform fonts, retaining requested
1343
+ weight, italic style and system text scaling. A missing custom face falls through
1344
+ to the next family rather than silently replacing a requested mono font with sans.
package/dist/index.js CHANGED
@@ -189,7 +189,12 @@ function serializeSimulatorSteps(steps) {
189
189
  };
190
190
  const line = (step) => {
191
191
  let arg = "";
192
- if ("paths" in step) {
192
+ if (step.command === "holdtab") {
193
+ const seconds = step.seconds ?? 6;
194
+ if (!step.text.trim() || step.text.includes("|") || number(seconds) < 2 || seconds > 10)
195
+ throw new Error("holdtab needs a nonempty tab name and 2\u201310 seconds");
196
+ arg = `${step.text} | ${seconds}`;
197
+ } else if ("paths" in step) {
193
198
  if (!step.paths.length || step.paths.length > 64)
194
199
  throw new Error("Expected 1\u201364 touch paths");
195
200
  for (const path of step.paths) {
package/dist/testing.d.ts CHANGED
@@ -61,6 +61,12 @@ export type SimulatorStep = {
61
61
  | {
62
62
  command: 'touches';
63
63
  paths: TimedTouchPoint[][];
64
+ }
65
+ /** Standard XCTest tab hold; independent screenshots must be captured before release. */
66
+ | {
67
+ command: 'holdtab';
68
+ text: string;
69
+ seconds?: number;
64
70
  } | {
65
71
  command: 'field';
66
72
  index: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.304",
3
+ "version": "0.0.319",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -766,6 +766,13 @@ final class CraftChrome: ObservableObject {
766
766
  @Published var tabAccessory: Tab?
767
767
  private weak var tabAccessoryOwner: AnyObject?
768
768
  @Published var selected: String?
769
+ /// The visible destination may be the accessory, independently of its owning stack.
770
+ @Published var visiblePath: String?
771
+ var accessorySelected: Bool {
772
+ guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
773
+ return path == accessory || path.hasPrefix(accessory + "/")
774
+ }
775
+ var highlightedTab: String? { accessorySelected ? nil : selected }
769
776
  let tabSwipeProgress = CraftTabSwipeProgress()
770
777
  private(set) var tabSwipePosition: CGFloat? {
771
778
  get { tabSwipeProgress.position }
@@ -1172,6 +1179,18 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
1172
1179
 
1173
1180
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
1174
1181
  struct CraftTabBarView: View {
1182
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
1183
+
1184
+ var body: some View {
1185
+ // ScaledMetric reads the environment of its owning view. Put the
1186
+ // metrics in a child so this compact-chrome cap reaches them too.
1187
+ CraftTabBarContent(viewerTextSize: dynamicTypeSize)
1188
+ .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1189
+ }
1190
+ }
1191
+
1192
+ private struct CraftTabBarContent: View {
1193
+ let viewerTextSize: DynamicTypeSize
1175
1194
  @ObservedObject private var chrome = CraftChrome.shared
1176
1195
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
1177
1196
  @Environment(\.scenePhase) private var scenePhase
@@ -1183,7 +1202,7 @@ struct CraftTabBarView: View {
1183
1202
  @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
1184
1203
 
1185
1204
  var body: some View {
1186
- if chrome.tabBarVisible && !chrome.tabs.isEmpty {
1205
+ if !chrome.tabs.isEmpty {
1187
1206
  HStack(spacing: 8) {
1188
1207
  bar
1189
1208
  if let accessory = chrome.tabAccessory {
@@ -1191,8 +1210,13 @@ struct CraftTabBarView: View {
1191
1210
  surface {
1192
1211
  Image(systemName: accessory.symbol)
1193
1212
  .font(.system(size: iconSize))
1194
- .foregroundStyle(Color.primary)
1213
+ .foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
1195
1214
  .frame(width: 62, height: 62)
1215
+ .background {
1216
+ if chrome.accessorySelected {
1217
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1218
+ }
1219
+ }
1196
1220
  }
1197
1221
  .contentShape(Rectangle())
1198
1222
  .overlay(alignment: .topTrailing) {
@@ -1207,9 +1231,17 @@ struct CraftTabBarView: View {
1207
1231
  }
1208
1232
  }
1209
1233
  .buttonStyle(.plain)
1234
+ .accessibilityShowsLargeContentViewer {
1235
+ Image(systemName: accessory.symbol)
1236
+ Text(accessory.accessibilityLabel ?? accessory.title)
1237
+ }
1238
+ // Keep accessibility intent for the viewer; displayed
1239
+ // sizes come from the bounded metrics owned above.
1240
+ .environment(\.dynamicTypeSize, viewerTextSize)
1210
1241
  .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1211
1242
  .accessibilityValue(accessory.badge ?? "")
1212
1243
  .accessibilityIdentifier("craft-tab-accessory")
1244
+ .accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
1213
1245
  }
1214
1246
  }
1215
1247
  .padding(.horizontal, 16)
@@ -1219,9 +1251,14 @@ struct CraftTabBarView: View {
1219
1251
  .onAppear { report(proxy) }
1220
1252
  .onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
1221
1253
  })
1222
- .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
1254
+ // Keep the view mounted: a disappearing conditional inside a
1255
+ // child does not reliably animate in the UIKit navigation transaction.
1256
+ .offset(y: chrome.tabBarVisible || reduceMotion ? 0 : 120)
1257
+ .opacity(chrome.tabBarVisible ? 1 : 0)
1258
+ .allowsHitTesting(chrome.tabBarVisible)
1259
+ .accessibilityHidden(!chrome.tabBarVisible)
1260
+ .animation(.easeInOut(duration: reduceMotion ? 0.12 : 0.24), value: chrome.tabBarVisible)
1223
1261
  .ignoresSafeArea(.keyboard)
1224
- .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1225
1262
  }
1226
1263
  }
1227
1264
 
@@ -1260,7 +1297,7 @@ struct CraftTabBarView: View {
1260
1297
  private var items: some View {
1261
1298
  HStack(spacing: 0) {
1262
1299
  ForEach(chrome.tabs) { tab in
1263
- let isSelected = tab.id == chrome.selected
1300
+ let isSelected = tab.id == chrome.highlightedTab
1264
1301
  Button { chrome.tap(tab) } label: {
1265
1302
  VStack(spacing: 3) {
1266
1303
  ZStack(alignment: .topTrailing) {
@@ -1305,6 +1342,7 @@ struct CraftTabBarView: View {
1305
1342
  Image(systemName: symbol(tab.symbol, selected: isSelected))
1306
1343
  Text(tab.displayTitle)
1307
1344
  }
1345
+ .environment(\.dynamicTypeSize, viewerTextSize)
1308
1346
  .accessibilityIdentifier("craft-tab-\(tab.id)")
1309
1347
  .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
1310
1348
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
@@ -1312,7 +1350,7 @@ struct CraftTabBarView: View {
1312
1350
  }
1313
1351
  .background {
1314
1352
  CraftTabSelectionIndicator(
1315
- selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1353
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.highlightedTab }),
1316
1354
  count: chrome.tabs.count,
1317
1355
  cornerRadius: chrome.tabSelectionCornerRadius
1318
1356
  )
@@ -1664,6 +1664,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1664
1664
  (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1665
1665
  }
1666
1666
  currentTab = id
1667
+ if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
1667
1668
  for (key, other) in stacks { other.view.isHidden = key != id }
1668
1669
  nav.view.frame = view.bounds
1669
1670
  view.bringSubviewToFront(nav.view)
@@ -2042,6 +2043,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2042
2043
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
2043
2044
  lastNavigation = CACurrentMediaTime()
2044
2045
  if animated { CraftFramePacing.shared.begin("navigation") }
2046
+ if navigationController === visibleStack {
2047
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2048
+ }
2045
2049
  // The page draws its own header, and a tab's root screen its own
2046
2050
  // title unless it asked for the bar; a pushed native screen gets the
2047
2051
  // bar and its Back.
@@ -2084,6 +2088,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2084
2088
  hybrid.pageReset(to: rootPath)
2085
2089
  }
2086
2090
  guard navigationController === visibleStack else { return }
2091
+ // Also restore selection after a cancelled interactive pop.
2092
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2087
2093
  if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
2088
2094
  }
2089
2095
 
@@ -43,6 +43,7 @@ enum CraftNativeActions {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
45
  var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
46
+ values.append("nativePager")
46
47
  values.append("storageCompareAndSet")
47
48
  values.append("storageSetIfAbsent")
48
49
  values.append("viewScroll")
@@ -2744,6 +2744,7 @@ final class CraftNativeCelebration {
2744
2744
  }
2745
2745
 
2746
2746
  private class CraftNativeFlowView: UIStackView {
2747
+ var pageViewport: CGSize? { didSet { if pageViewport != oldValue { invalidateMeasurements() } } }
2747
2748
  var celebration: CraftNativeCelebration?
2748
2749
  func configureCelebration(_ raw: Any?) {
2749
2750
  if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
@@ -2966,6 +2967,7 @@ private class CraftNativeFlowView: UIStackView {
2966
2967
  }
2967
2968
 
2968
2969
  func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
2970
+ if let page = pageViewport { return CGSize(width: page.width * CGFloat(arrangedSubviews.filter { !$0.isHidden }.count), height: page.height) }
2969
2971
  let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
2970
2972
  if let cached = measurements[key] { return cached }
2971
2973
  let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
@@ -2977,6 +2979,12 @@ private class CraftNativeFlowView: UIStackView {
2977
2979
  super.layoutSubviews()
2978
2980
  craftApplyCornerRadius()
2979
2981
  celebration?.layout()
2982
+ if let page = pageViewport {
2983
+ for (index, child) in arrangedSubviews.filter({ !$0.isHidden }).enumerated() {
2984
+ child.frame = CGRect(x: CGFloat(index) * page.width, y: 0, width: page.width, height: page.height)
2985
+ }
2986
+ return
2987
+ }
2980
2988
  let scale = window?.screen.scale ?? traitCollection.displayScale
2981
2989
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
2982
2990
  var moved = false
@@ -4344,6 +4352,46 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4344
4352
  private var laidOutSize: CGSize = .zero
4345
4353
  private let pullToRefresh = UIRefreshControl()
4346
4354
  private var refreshHandler: (() -> Void)?
4355
+ private var authoredPageIndex: Int?
4356
+ private var pendingPageIndex: Int?
4357
+ private var currentPageIndex = 0
4358
+ private var pageAnimating = false
4359
+ private var reportedPage: (Int, Int)?
4360
+ private var pageHandler: ((Int, Int) -> Void)?
4361
+
4362
+ /// Opt-in viewport-sized pages. Ordinary horizontal scroll strips retain
4363
+ /// their intrinsic widths; only a controlled pageIndex enables this mode.
4364
+ func configurePager(_ raw: Any?, onSelect: ((Int, Int) -> Void)?) {
4365
+ let next = craftNativeNumber(raw).flatMap { number -> Int? in
4366
+ let value = number.doubleValue
4367
+ return value.isFinite ? Int(min(Double(Int.max / 2), max(0, value))) : nil
4368
+ }
4369
+ if next != authoredPageIndex { pendingPageIndex = next; contentNeedsLayout = true }
4370
+ authoredPageIndex = next
4371
+ pageHandler = onSelect
4372
+ if next == nil { contentStack.pageViewport = nil; reportedPage = nil; pageAnimating = false }
4373
+ setNeedsLayout()
4374
+ }
4375
+
4376
+ func beginPageDrag() { pageAnimating = false }
4377
+
4378
+ func finishPageMotion() {
4379
+ pageAnimating = false
4380
+ DispatchQueue.main.async { [weak self] in self?.settlePage() }
4381
+ }
4382
+
4383
+ func settlePage() {
4384
+ guard authoredPageIndex != nil, bounds.width > 0, !pageAnimating,
4385
+ !isTracking, !isDragging, !isDecelerating else { return }
4386
+ let pages = contentStack.arrangedSubviews.filter { !$0.isHidden }
4387
+ let index = min(max(0, Int((contentOffset.x / bounds.width).rounded())), max(0, pages.count - 1))
4388
+ currentPageIndex = index
4389
+ for (i, child) in pages.enumerated() { child.accessibilityElementsHidden = i != index }
4390
+ if reportedPage?.0 != index || reportedPage?.1 != pages.count {
4391
+ reportedPage = (index, pages.count)
4392
+ pageHandler?(index, pages.count)
4393
+ }
4394
+ }
4347
4395
 
4348
4396
  override init(frame: CGRect) {
4349
4397
  super.init(frame: frame)
@@ -4384,6 +4432,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4384
4432
  /// The size of the content laid out across the visible width (or, sideways,
4385
4433
  /// height), which is also the scroll view's own size when nothing fixes it.
4386
4434
  func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
4435
+ if authoredPageIndex != nil && !isVertical {
4436
+ let pageWidth = max(0, width ?? bounds.width)
4437
+ let pageHeight = max(0, height ?? bounds.height)
4438
+ return CGSize(width: pageWidth * CGFloat(contentStack.arrangedSubviews.filter { !$0.isHidden }.count), height: pageHeight)
4439
+ }
4387
4440
  if isVertical {
4388
4441
  let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
4389
4442
  return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
@@ -4406,7 +4459,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4406
4459
  craftApplyCornerRadius()
4407
4460
  if contentNeedsLayout || laidOutSize != bounds.size {
4408
4461
  contentNeedsLayout = false
4462
+ let resized = laidOutSize != bounds.size
4409
4463
  laidOutSize = bounds.size
4464
+ if authoredPageIndex != nil && !isVertical { contentStack.pageViewport = bounds.size }
4410
4465
  var size = contentFitting(width: bounds.width, height: bounds.height)
4411
4466
  if isVertical {
4412
4467
  size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
@@ -4416,12 +4471,31 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4416
4471
  let frame = CGRect(origin: .zero, size: size)
4417
4472
  if contentStack.frame != frame { contentStack.frame = frame }
4418
4473
  if contentSize != size { contentSize = size }
4474
+ if authoredPageIndex != nil && !isVertical {
4475
+ contentStack.layoutIfNeeded()
4476
+ let count = contentStack.arrangedSubviews.filter { !$0.isHidden }.count
4477
+ let index = min(max(0, pendingPageIndex ?? currentPageIndex), max(0, count - 1))
4478
+ let target = CGPoint(x: CGFloat(index) * bounds.width, y: 0)
4479
+ let moves = pendingPageIndex != nil || resized || index != currentPageIndex
4480
+ let animated = moves && target != contentOffset && !resized && !UIAccessibility.isReduceMotionEnabled
4481
+ if moves {
4482
+ pageAnimating = animated
4483
+ setContentOffset(target, animated: animated)
4484
+ }
4485
+ pendingPageIndex = nil
4486
+ currentPageIndex = index
4487
+ if !animated && !isTracking && !isDragging && !isDecelerating { settlePage() }
4488
+ }
4419
4489
  }
4420
4490
  // A scroll view lays out on every frame of a scroll: the sticky
4421
4491
  // headers follow it here, over content placed by this same pass.
4422
4492
  if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
4423
4493
  updateStickyHeaders()
4424
4494
  scrollToPendingTarget()
4495
+ // A nonanimated target changes the offset synchronously inside this
4496
+ // layout pass. Rebase the headers against that actual viewport, not
4497
+ // the viewport from before the target was applied.
4498
+ updateStickyHeaders()
4425
4499
  }
4426
4500
 
4427
4501
  // MARK: Sticky headers
@@ -4444,7 +4518,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4444
4518
  view.center.y - view.bounds.height / 2
4445
4519
  }
4446
4520
 
4447
- private func updateStickyHeaders() {
4521
+ fileprivate func updateStickyHeaders() {
4448
4522
  let children = contentStack.arrangedSubviews
4449
4523
  let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
4450
4524
  for view in stickyViews where !headers.contains(where: { $0 === view }) {
@@ -4617,11 +4691,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4617
4691
  craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
4618
4692
  }
4619
4693
 
4620
- func setAxis(_ axis: NSLayoutConstraint.Axis) {
4694
+ func setAxis(_ axis: NSLayoutConstraint.Axis, resetBounce: Bool = true) {
4621
4695
  if contentStack.axis != axis { setContentNeedsLayout() }
4622
4696
  contentStack.axis = axis
4623
- alwaysBounceVertical = axis == .vertical
4624
- alwaysBounceHorizontal = axis == .horizontal
4697
+ if resetBounce {
4698
+ alwaysBounceVertical = axis == .vertical
4699
+ alwaysBounceHorizontal = axis == .horizontal
4700
+ }
4625
4701
  }
4626
4702
  }
4627
4703
 
@@ -8461,6 +8537,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8461
8537
  scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
8462
8538
  scroll.bounces = props["bounces"] as? Bool ?? true
8463
8539
  scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
8540
+ let pageHandler = nonEmptyHandler(events["onPageSelected"])
8541
+ scroll.configurePager(props["pageIndex"], onSelect: pageHandler.map { handler in
8542
+ { [weak self] index, count in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["index": index, "count": count]]) }
8543
+ })
8464
8544
  scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
8465
8545
  scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
8466
8546
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
@@ -8492,6 +8572,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8492
8572
  contentStyle.merge(override) { _, next in next }
8493
8573
  }
8494
8574
  configureStack(scroll.contentStack, style: contentStyle)
8575
+ // Content-style defaults are vertical; the scroll host's explicit
8576
+ // axis must survive that shared container configuration without
8577
+ // resetting the authored bounce options applied above.
8578
+ scroll.setAxis(direction, resetBounce: false)
8495
8579
  reconcileChildren(children.filter {
8496
8580
  guard let child = $0 as? [String: Any] else { return true }
8497
8581
  return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
@@ -9712,10 +9796,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9712
9796
  for: size,
9713
9797
  compatibleWith: traitCollection
9714
9798
  )
9715
- var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: scaledSize) }
9716
- ?? .systemFont(ofSize: scaledSize, weight: weight)
9799
+ let families = (style["fontFamily"] as? String ?? "").split(separator: ",").map {
9800
+ $0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
9801
+ }
9802
+ let selectedFont = families.lazy.compactMap { family -> UIFont? in
9803
+ switch family.lowercased() {
9804
+ case "monospace", "ui-monospace": return .monospacedSystemFont(ofSize: scaledSize, weight: weight)
9805
+ case "serif", "ui-serif":
9806
+ let system = UIFont.systemFont(ofSize: scaledSize, weight: weight)
9807
+ return system.fontDescriptor.withDesign(.serif).map { UIFont(descriptor: $0, size: scaledSize) }
9808
+ case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system": return .systemFont(ofSize: scaledSize, weight: weight)
9809
+ default:
9810
+ guard let custom = UIFont(name: family, size: scaledSize) else { return nil }
9811
+ let weighted = custom.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight.rawValue]])
9812
+ var traits = custom.fontDescriptor.symbolicTraits
9813
+ if weight >= .semibold { traits.insert(.traitBold) } else { traits.remove(.traitBold) }
9814
+ let descriptor = weighted.withSymbolicTraits(traits) ?? weighted
9815
+ return UIFont(descriptor: descriptor, size: scaledSize)
9816
+ }
9817
+ }.first
9818
+ var font = selectedFont ?? .systemFont(ofSize: scaledSize, weight: weight)
9717
9819
  if style["fontStyle"] as? String == "italic",
9718
- let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
9820
+ let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) {
9719
9821
  font = UIFont(descriptor: descriptor, size: scaledSize)
9720
9822
  }
9721
9823
  return font
@@ -10502,6 +10604,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10502
10604
  }
10503
10605
 
10504
10606
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
10607
+ // Sticky positioning is a native scroll behavior and must also run
10608
+ // without a JavaScript onScroll subscription, including animations.
10609
+ (scrollView as? CraftNativeScrollView)?.updateStickyHeaders()
10505
10610
  guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
10506
10611
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10507
10612
  }
@@ -10510,12 +10615,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10510
10615
 
10511
10616
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
10512
10617
  CraftFramePacing.shared.begin(pacingName)
10618
+ (scrollView as? CraftNativeScrollView)?.beginPageDrag()
10513
10619
  guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
10514
10620
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10515
10621
  }
10516
10622
 
10517
10623
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
10518
- if !decelerate { CraftFramePacing.shared.end(pacingName) }
10624
+ if !decelerate { CraftFramePacing.shared.end(pacingName); (scrollView as? CraftNativeScrollView)?.finishPageMotion() }
10519
10625
  guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
10520
10626
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10521
10627
  }
@@ -10527,10 +10633,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10527
10633
 
10528
10634
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
10529
10635
  CraftFramePacing.shared.end(pacingName)
10636
+ (scrollView as? CraftNativeScrollView)?.finishPageMotion()
10530
10637
  guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
10531
10638
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10532
10639
  }
10533
10640
 
10641
+ func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
10642
+ (scrollView as? CraftNativeScrollView)?.finishPageMotion()
10643
+ }
10644
+
10534
10645
  private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
10535
10646
  [
10536
10647
  "contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
@@ -22,6 +22,7 @@ import Darwin
22
22
  /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
23
23
  /// tapid <identifier>
24
24
  /// longpressid <identifier> holds a hittable native element to open its edit menu
25
+ /// holdtab <name> [| secs] standard XCTest hold (2–10s, default 6), BEGIN/END timing receipt
25
26
  /// touches <JSON> timed normalized touch paths in one native dispatch
26
27
  /// tapxy <x> <y> normalised 0…1 of the screen
27
28
  /// tapexact <label> a button whose label is exactly this
@@ -381,6 +382,43 @@ final class DriverTests: XCTestCase {
381
382
  }
382
383
  target.press(forDuration: 1.2)
383
384
  note("longpressid \(arg): ok")
385
+ case "holdtab":
386
+ let parts = arg.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
387
+ guard parts.count <= 2, let name = parts.first, !name.isEmpty,
388
+ let duration = parts.count == 2 ? Double(parts[1]) : 6,
389
+ duration.isFinite, (2...10).contains(duration) else {
390
+ check(false, "holdtab needs a tab name and 2–10 seconds")
391
+ return
392
+ }
393
+ guard let button = poll(10, { tabButton(name) }) else {
394
+ check(false, "holdtab cannot find tab \(name)")
395
+ return
396
+ }
397
+ let frame = button.frame
398
+ let coordinate = button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
399
+ let startedAt = Date().timeIntervalSince1970
400
+ let startedUptime = ProcessInfo.processInfo.systemUptime
401
+ // BEGIN arms the independent screenshot watcher. XCTest still
402
+ // performs its normal idle checks before synthesizing the press;
403
+ // the watcher must wait for that synthesis, not infer touch-down.
404
+ note("BEGIN holdtab \(name) | \(duration) | \(startedAt)")
405
+ fflush(nil)
406
+ coordinate.press(forDuration: duration)
407
+ let returnedAt = Date().timeIntervalSince1970
408
+ let returnedUptime = ProcessInfo.processInfo.systemUptime
409
+ note("END holdtab \(name) | \(returnedAt)")
410
+ let receipt: [String: Any] = [
411
+ "tab": name, "requestedSeconds": duration,
412
+ "input": "XCUICoordinate.press(forDuration:)",
413
+ "startedAt": startedAt, "returnedAt": returnedAt,
414
+ "startedUptime": startedUptime, "returnedUptime": returnedUptime,
415
+ "frame": ["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height],
416
+ "note": "Call times include XCTest idle checks; during-hold screenshots require an independent synthesis-timed capture.",
417
+ ]
418
+ if let data = try? JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys]) {
419
+ post("/save?name=holdtab-\(Int(startedUptime * 1000)).json", data)
420
+ }
421
+ check(true, "holdtab \(name): standard \(duration)s press returned")
384
422
  case "touches":
385
423
  do {
386
424
  guard let data = arg.data(using: .utf8),