@craft-native/ios 0.0.235 → 0.0.237

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
@@ -2,6 +2,18 @@
2
2
 
3
3
  Build native iOS apps with web technologies using Craft.
4
4
 
5
+ ## Native celebration
6
+
7
+ Any native `View` may opt into a finite card entrance, light sweep and confetti
8
+ burst with `:celebration="{ replay: replayCount, enabled: true }"`. The numeric
9
+ `replay` value identifies the request: unrelated renders keep the current run;
10
+ increment it to replay. `enabled: false` displays an existing card quietly.
11
+ The effect takes three seconds, uses native animation frames, never captures
12
+ touches, and keeps the view's authored transform. iOS Reduce Motion and Android's
13
+ disabled animations show the complete content without movement or confetti.
14
+ Leaving the screen, detaching the view or backgrounding the app cancels the
15
+ decoration. Resuming does not replay it. Available on both native renderers.
16
+
5
17
  ## Features
6
18
 
7
19
  ### Core
@@ -0,0 +1,11 @@
1
+ export interface CraftAppShortcut {
2
+ id: string;
3
+ title: string;
4
+ subtitle?: string;
5
+ symbol: string;
6
+ url: string;
7
+ /** Each phrase includes exactly one `{app}` token for Siri's app name. */
8
+ phrases: string[];
9
+ }
10
+ /** Build-owned App Intents survive shell restoration and need no app Swift. */
11
+ export declare function appShortcutsSwift(shortcuts?: CraftAppShortcut[], schemes?: string[]): string;
package/dist/cli.js CHANGED
@@ -10,6 +10,60 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync as readdirSync
10
10
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
11
11
  var {$ } = globalThis.Bun;
12
12
 
13
+ // src/app-shortcuts.ts
14
+ function literal(value) {
15
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
16
+ }
17
+ function appShortcutsSwift(shortcuts = [], schemes = []) {
18
+ if (shortcuts.length > 10)
19
+ throw new Error("iOS supports at most ten app shortcuts");
20
+ const ids = new Set;
21
+ for (const shortcut of shortcuts) {
22
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(shortcut.id) || ids.has(shortcut.id))
23
+ throw new Error(`Invalid or duplicate app shortcut id: ${shortcut.id}`);
24
+ ids.add(shortcut.id);
25
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(shortcut.url)?.[1];
26
+ if (!scheme || !schemes.includes(scheme) || /[\s\\]/.test(shortcut.url))
27
+ throw new Error(`App shortcut ${shortcut.id} needs a registered app URL scheme`);
28
+ if (!shortcut.title?.trim() || !shortcut.symbol?.trim() || !shortcut.phrases?.length || shortcut.phrases.some((phrase) => phrase.split("{app}").length !== 2))
29
+ throw new Error(`App shortcut ${shortcut.id} needs a title, symbol and phrases with exactly one {app}`);
30
+ }
31
+ const name = (id) => `CraftShortcut${id.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("")}Intent`;
32
+ const intents = shortcuts.map((shortcut) => `@available(iOS 16.4, *)
33
+ struct ${name(shortcut.id)}: AppIntent {
34
+ static var title: LocalizedStringResource = ${literal(shortcut.title)}
35
+ static var description = IntentDescription(${literal(shortcut.subtitle ?? shortcut.title)})
36
+ static var openAppWhenRun: Bool = true
37
+ @MainActor
38
+ func perform() async throws -> some IntentResult {
39
+ if let url = URL(string: ${literal(shortcut.url)}) { await UIApplication.shared.open(url) }
40
+ return .result()
41
+ }
42
+ }`).join(`
43
+
44
+ `);
45
+ const entries = shortcuts.map((shortcut) => ` AppShortcut(
46
+ intent: ${name(shortcut.id)}(),
47
+ phrases: [${shortcut.phrases.map((phrase) => literal(phrase).replace("{app}", "\\(.applicationName)")).join(", ")}],
48
+ shortTitle: ${literal(shortcut.title)},
49
+ systemImageName: ${literal(shortcut.symbol)}
50
+ )`).join(`
51
+ `);
52
+ return `// Generated by Craft from appShortcuts. Restored from the package at build time.
53
+ import AppIntents
54
+ import UIKit
55
+
56
+ ${intents}
57
+ ${shortcuts.length ? `
58
+ @available(iOS 16.4, *)
59
+ struct CraftConfiguredAppShortcuts: AppShortcutsProvider {
60
+ static var appShortcuts: [AppShortcut] {
61
+ ${entries}
62
+ }
63
+ }
64
+ ` : ""}`;
65
+ }
66
+
13
67
  // src/source-audit.ts
14
68
  import { readdirSync } from "fs";
15
69
  import { join, relative, sep } from "path";
@@ -890,11 +944,13 @@ function writeShell(output, config) {
890
944
  if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
891
945
  throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
892
946
  const name = productName(config.appName);
947
+ const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
893
948
  mkdirSync(join3(output, "Sources"), { recursive: true });
894
949
  mkdirSync(join3(output, "Shared"), { recursive: true });
895
950
  const swiftTemplate = readFileSync(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
896
951
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
897
952
  writeFileSync(join3(output, "Sources", `${name}App.swift`), swiftSource);
953
+ writeFileSync(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
898
954
  cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
899
955
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
900
956
  cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
package/dist/index.d.ts CHANGED
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Generates native iOS apps from web content using WKWebView.
5
5
  */
6
+ import { type CraftAppShortcut } from './app-shortcuts.js';
6
7
  /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
7
8
  export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
8
9
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
9
10
  export declare function resolveCraftVersion(startDir?: string): string;
10
11
  export type CraftAppearance = 'light' | 'dark' | 'system';
11
12
  export interface CraftConfig {
13
+ appShortcuts?: CraftAppShortcut[];
12
14
  /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
13
15
  nativeFonts?: Record<string, {
14
16
  src: string;
package/dist/index.js CHANGED
@@ -4,6 +4,60 @@ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSy
4
4
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
5
5
  var {$ } = globalThis.Bun;
6
6
 
7
+ // src/app-shortcuts.ts
8
+ function literal(value) {
9
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
10
+ }
11
+ function appShortcutsSwift(shortcuts = [], schemes = []) {
12
+ if (shortcuts.length > 10)
13
+ throw new Error("iOS supports at most ten app shortcuts");
14
+ const ids = new Set;
15
+ for (const shortcut of shortcuts) {
16
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(shortcut.id) || ids.has(shortcut.id))
17
+ throw new Error(`Invalid or duplicate app shortcut id: ${shortcut.id}`);
18
+ ids.add(shortcut.id);
19
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(shortcut.url)?.[1];
20
+ if (!scheme || !schemes.includes(scheme) || /[\s\\]/.test(shortcut.url))
21
+ throw new Error(`App shortcut ${shortcut.id} needs a registered app URL scheme`);
22
+ if (!shortcut.title?.trim() || !shortcut.symbol?.trim() || !shortcut.phrases?.length || shortcut.phrases.some((phrase) => phrase.split("{app}").length !== 2))
23
+ throw new Error(`App shortcut ${shortcut.id} needs a title, symbol and phrases with exactly one {app}`);
24
+ }
25
+ const name = (id) => `CraftShortcut${id.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("")}Intent`;
26
+ const intents = shortcuts.map((shortcut) => `@available(iOS 16.4, *)
27
+ struct ${name(shortcut.id)}: AppIntent {
28
+ static var title: LocalizedStringResource = ${literal(shortcut.title)}
29
+ static var description = IntentDescription(${literal(shortcut.subtitle ?? shortcut.title)})
30
+ static var openAppWhenRun: Bool = true
31
+ @MainActor
32
+ func perform() async throws -> some IntentResult {
33
+ if let url = URL(string: ${literal(shortcut.url)}) { await UIApplication.shared.open(url) }
34
+ return .result()
35
+ }
36
+ }`).join(`
37
+
38
+ `);
39
+ const entries = shortcuts.map((shortcut) => ` AppShortcut(
40
+ intent: ${name(shortcut.id)}(),
41
+ phrases: [${shortcut.phrases.map((phrase) => literal(phrase).replace("{app}", "\\(.applicationName)")).join(", ")}],
42
+ shortTitle: ${literal(shortcut.title)},
43
+ systemImageName: ${literal(shortcut.symbol)}
44
+ )`).join(`
45
+ `);
46
+ return `// Generated by Craft from appShortcuts. Restored from the package at build time.
47
+ import AppIntents
48
+ import UIKit
49
+
50
+ ${intents}
51
+ ${shortcuts.length ? `
52
+ @available(iOS 16.4, *)
53
+ struct CraftConfiguredAppShortcuts: AppShortcutsProvider {
54
+ static var appShortcuts: [AppShortcut] {
55
+ ${entries}
56
+ }
57
+ }
58
+ ` : ""}`;
59
+ }
60
+
7
61
  // src/source-audit.ts
8
62
  import { readdirSync } from "fs";
9
63
  import { join, relative, sep } from "path";
@@ -1073,11 +1127,13 @@ function writeShell(output, config) {
1073
1127
  if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
1074
1128
  throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
1075
1129
  const name = productName(config.appName);
1130
+ const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
1076
1131
  mkdirSync2(join3(output, "Sources"), { recursive: true });
1077
1132
  mkdirSync2(join3(output, "Shared"), { recursive: true });
1078
1133
  const swiftTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
1079
1134
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
1080
1135
  writeFileSync2(join3(output, "Sources", `${name}App.swift`), swiftSource);
1136
+ writeFileSync2(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
1081
1137
  cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
1082
1138
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
1083
1139
  cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.235",
3
+ "version": "0.0.237",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -205,6 +205,12 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
205
205
  // push, a refresh, a route recording's location) draws nothing, and
206
206
  // its events still need someone to hear them.
207
207
  CraftHybrid.shared.configure(config)
208
+ if let shortcuts = config.appShortcuts {
209
+ application.shortcutItems = shortcuts.prefix(4).map { shortcut in
210
+ UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
211
+ icon: UIApplicationShortcutIcon(systemImageName: shortcut.symbol), userInfo: ["url": shortcut.url as NSString])
212
+ }
213
+ }
208
214
  if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
209
215
  CraftEventManager.shared.handleShortcut(shortcut)
210
216
  // Returning false tells UIKit the launch-time item was handled and
@@ -1399,7 +1405,17 @@ extension CraftConfig {
1399
1405
  }
1400
1406
 
1401
1407
  // MARK: - Configuration
1408
+ struct CraftConfiguredShortcut: Codable {
1409
+ var id: String
1410
+ var title: String
1411
+ var subtitle: String?
1412
+ var symbol: String
1413
+ var url: String
1414
+ var phrases: [String]
1415
+ }
1416
+
1402
1417
  struct CraftConfig: Codable {
1418
+ var appShortcuts: [CraftConfiguredShortcut]? = nil
1403
1419
  var appName: String = "Craft App"
1404
1420
  var bundleId: String = "com.craft.app"
1405
1421
  /// Opt-in UIKit + JavaScriptCore screen; existing apps keep WKWebView.
@@ -1161,8 +1161,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1161
1161
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
1162
1162
  private var initialCameraKey: String?
1163
1163
  private var pendingFitKey: String?
1164
+ private var pendingRegion: MKCoordinateRegion?
1164
1165
  private(set) var markerHandlers: [String: String] = [:]
1165
1166
  var onMarkerTap: ((String) -> Void)?
1167
+ var onMapPress: ((CLLocationCoordinate2D) -> Void)?
1168
+ var onRegionChangeComplete: ((MKCoordinateRegion, Double, Double) -> Void)?
1169
+ private var userChangingCamera = false
1170
+ private var authoredPitch: Double?
1171
+ private var authoredHeading: Double?
1172
+ private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1173
+ private var markerBearings: [String: Double] = [:]
1174
+ private var tileOverlay: MKTileOverlay?
1175
+ private var tileTemplate: String?
1176
+ private lazy var tileAttribution: UILabel = {
1177
+ let label = UILabel()
1178
+ label.font = .systemFont(ofSize: 9)
1179
+ label.textColor = .darkGray
1180
+ label.backgroundColor = UIColor.white.withAlphaComponent(0.85)
1181
+ label.translatesAutoresizingMaskIntoConstraints = false
1182
+ label.numberOfLines = 2
1183
+ addSubview(label)
1184
+ NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
1185
+ return label
1186
+ }()
1166
1187
 
1167
1188
  override init(frame: CGRect) {
1168
1189
  super.init(frame: frame)
@@ -1170,6 +1191,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1170
1191
  showsCompass = false
1171
1192
  showsScale = false
1172
1193
  isRotateEnabled = false
1194
+ let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
1195
+ tap.cancelsTouchesInView = false
1196
+ addGestureRecognizer(tap)
1173
1197
  }
1174
1198
 
1175
1199
  required init?(coder: NSCoder) {
@@ -1183,6 +1207,18 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1183
1207
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
1184
1208
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1185
1209
  mapType = Self.mapType(props["mapType"] as? String)
1210
+ configureTiles(props["tileOverlay"] as? [String: Any])
1211
+ showsCompass = props["showsCompass"] as? Bool ?? false
1212
+ showsScale = props["showsScale"] as? Bool ?? false
1213
+ authoredPitch = Self.numeric(props["cameraPitch"])
1214
+ authoredHeading = Self.numeric(props["cameraHeading"])
1215
+ let padding = props["edgePadding"] as? [String: Any]
1216
+ func inset(_ name: String) -> CGFloat {
1217
+ guard let value = Self.numeric(padding?[name]), value.isFinite else { return 24 }
1218
+ return CGFloat(min(10_000, max(0, value)))
1219
+ }
1220
+ edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
1221
+ var nextBearings: [String: Double] = [:]
1186
1222
  // Showing the user dot must not trigger a permission prompt from a
1187
1223
  // renderer pass. The app's existing location capability owns that
1188
1224
  // prompt; a map only opts in after authorization is already granted.
@@ -1203,6 +1239,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1203
1239
  if type == "mapmarker" || type == "marker" {
1204
1240
  guard let point = points.first else { continue }
1205
1241
  nextMarkers[id] = (point, childProps["title"] as? String)
1242
+ if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
1243
+ nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
1244
+ }
1206
1245
  if let handler = nonEmptyHandler(childEvents["onPress"])
1207
1246
  ?? nonEmptyHandler(childEvents["onMarkerPress"]) {
1208
1247
  nextHandlers[id] = handler
@@ -1216,12 +1255,14 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1216
1255
  }
1217
1256
  }
1218
1257
 
1258
+ markerBearings = nextBearings
1219
1259
  let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
1220
1260
  removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
1221
1261
  for (id, value) in nextMarkers {
1222
1262
  if let annotation = annotationsByID[id] {
1223
1263
  annotation.coordinate = value.coordinate
1224
1264
  annotation.title = value.title
1265
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1225
1266
  } else {
1226
1267
  let annotation = MKPointAnnotation()
1227
1268
  annotation.coordinate = value.coordinate
@@ -1266,17 +1307,24 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1266
1307
  // `region` is controlled: an authored change is allowed to move
1267
1308
  // the camera, even when the map itself was already mounted.
1268
1309
  pendingFitKey = nil
1269
- setRegion(region, animated: false)
1310
+ pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : region
1311
+ if abs(self.region.center.latitude - region.center.latitude) > 0.00000001
1312
+ || abs(self.region.center.longitude - region.center.longitude) > 0.00000001
1313
+ || abs(self.region.span.latitudeDelta - region.span.latitudeDelta) > 0.00000001
1314
+ || abs(self.region.span.longitudeDelta - region.span.longitudeDelta) > 0.00000001 {
1315
+ setRegion(region, animated: false)
1316
+ }
1270
1317
  initialCameraKey = nil
1271
1318
  } else if let initial = Self.region(props["initialRegion"]) {
1272
1319
  pendingFitKey = nil
1273
1320
  let key = "region:\(Self.regionKey(initial))"
1274
1321
  if initialCameraKey != key {
1322
+ pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : initial
1275
1323
  setRegion(initial, animated: false)
1276
1324
  initialCameraKey = key
1277
1325
  }
1278
1326
  } else if props["fitToBounds"] as? Bool == true {
1279
- let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }))"
1327
+ let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates })):\(edgePadding)"
1280
1328
  if initialCameraKey != key {
1281
1329
  // A native screen can reconcile before UIKit assigns the map
1282
1330
  // its final safe-area-constrained bounds. Keep the fit
@@ -1292,10 +1340,96 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1292
1340
  initialCameraKey = nil
1293
1341
  pendingFitKey = nil
1294
1342
  }
1343
+ applyCameraOrientation()
1344
+ }
1345
+
1346
+ private func configureTiles(_ options: [String: Any]?) {
1347
+ let template = options?["urlTemplate"] as? String
1348
+ let credit = options?["attribution"] as? String ?? ""
1349
+ let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
1350
+ let next = valid && !credit.isEmpty ? template : nil
1351
+ if tileTemplate != next {
1352
+ if let overlay = tileOverlay { removeOverlay(overlay) }
1353
+ tileOverlay = nil
1354
+ tileTemplate = next
1355
+ if let next {
1356
+ let overlay = MKTileOverlay(urlTemplate: next)
1357
+ overlay.canReplaceMapContent = true
1358
+ tileOverlay = overlay
1359
+ insertOverlay(overlay, at: 0, level: .aboveRoads)
1360
+ }
1361
+ }
1362
+ tileAttribution.text = next == nil ? nil : credit
1363
+ tileAttribution.isHidden = next == nil
1364
+ }
1365
+
1366
+ private func applyCameraOrientation() {
1367
+ // Region-to-altitude conversion needs a measured viewport. Applying
1368
+ // pitch to the zero-altitude camera before layout clamps it to zero.
1369
+ guard bounds.width > 0, bounds.height > 0 else { return }
1370
+ let next = camera.copy() as! MKMapCamera
1371
+ var changed = false
1372
+ if let pitch = authoredPitch, pitch.isFinite {
1373
+ let value = CGFloat(min(60, max(0, pitch)))
1374
+ if abs(next.pitch - value) > 0.01 { next.pitch = value; changed = true }
1375
+ }
1376
+ if let heading = authoredHeading, heading.isFinite {
1377
+ let value = heading.truncatingRemainder(dividingBy: 360)
1378
+ if abs(next.heading - value) > 0.01 { next.heading = value; changed = true }
1379
+ }
1380
+ if changed { setCamera(next, animated: false) }
1381
+ }
1382
+
1383
+ @objc private func pressedMap(_ gesture: UITapGestureRecognizer) {
1384
+ guard gesture.state == .ended, onMapPress != nil else { return }
1385
+ let point = gesture.location(in: self)
1386
+ var touched = hitTest(point, with: nil)
1387
+ while let view = touched, view !== self {
1388
+ if view is MKAnnotationView { return }
1389
+ touched = view.superview
1390
+ }
1391
+ onMapPress?(convert(point, toCoordinateFrom: self))
1392
+ }
1393
+
1394
+ func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
1395
+ func interacting(_ view: UIView) -> Bool {
1396
+ if view.gestureRecognizers?.contains(where: { [.began, .changed, .ended].contains($0.state) }) == true { return true }
1397
+ return view.subviews.contains(where: interacting)
1398
+ }
1399
+ userChangingCamera = userChangingCamera || interacting(self)
1400
+ }
1401
+
1402
+ func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
1403
+ for (id, annotation) in annotationsByID {
1404
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1405
+ }
1406
+ if userChangingCamera {
1407
+ userChangingCamera = false
1408
+ onRegionChangeComplete?(region, camera.heading, Double(camera.pitch))
1409
+ }
1410
+ }
1411
+
1412
+ private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1413
+ if let bearing = markerBearings[id] {
1414
+ let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
1415
+ .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1416
+ view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
1417
+ view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1418
+ } else {
1419
+ view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1420
+ .withTintColor(.systemRed, renderingMode: .alwaysOriginal)
1421
+ view.transform = .identity
1422
+ }
1295
1423
  }
1296
1424
 
1297
1425
  override func layoutSubviews() {
1298
1426
  super.layoutSubviews()
1427
+ if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
1428
+ if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
1429
+ pendingRegion = nil
1430
+ setRegion(region, animated: false)
1431
+ applyCameraOrientation()
1432
+ }
1299
1433
  guard let key = pendingFitKey else { return }
1300
1434
  pendingFitKey = nil
1301
1435
  guard fitToOverlays() else {
@@ -1303,6 +1437,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1303
1437
  return
1304
1438
  }
1305
1439
  initialCameraKey = key
1440
+ applyCameraOrientation()
1306
1441
  }
1307
1442
 
1308
1443
  @discardableResult
@@ -1315,17 +1450,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1315
1450
  rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
1316
1451
  }
1317
1452
  guard !rect.isNull else { return false }
1318
- let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1453
+ var inset = edgePadding
1454
+ let horizontal = inset.left + inset.right
1455
+ let vertical = inset.top + inset.bottom
1456
+ let availableWidth = max(0, bounds.width - 40)
1457
+ let availableHeight = max(0, bounds.height - 40)
1458
+ if horizontal > availableWidth && horizontal > 0 {
1459
+ inset.left *= availableWidth / horizontal
1460
+ inset.right *= availableWidth / horizontal
1461
+ }
1462
+ if vertical > availableHeight && vertical > 0 {
1463
+ inset.top *= availableHeight / vertical
1464
+ inset.bottom *= availableHeight / vertical
1465
+ }
1319
1466
  setVisibleMapRect(rect, edgePadding: inset, animated: false)
1320
1467
  return true
1321
1468
  }
1322
1469
 
1323
1470
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
1324
1471
  guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
1325
- let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
1326
- ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1472
+ let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
1473
+ ?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1327
1474
  view.annotation = annotation
1328
- view.markerTintColor = .systemRed
1475
+ configureMarkerView(view, id: id)
1329
1476
  view.accessibilityIdentifier = id
1330
1477
  return view
1331
1478
  }
@@ -1337,6 +1484,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1337
1484
  }
1338
1485
 
1339
1486
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
1487
+ if let tiles = overlay as? MKTileOverlay { return MKTileOverlayRenderer(tileOverlay: tiles) }
1340
1488
  let renderer = MKPolylineRenderer(overlay: overlay)
1341
1489
  renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
1342
1490
  renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
@@ -1834,7 +1982,172 @@ extension UIView {
1834
1982
  }
1835
1983
  }
1836
1984
 
1985
+ /// A finite, opt-in native celebration. Reconciliation with the same replay
1986
+ /// value never restarts it; Reduce Motion always leaves the complete card still.
1987
+ struct CraftNativeCelebrationSpec: Equatable {
1988
+ let replay: Double
1989
+ let enabled: Bool
1990
+
1991
+ init?(_ raw: Any?) {
1992
+ guard let value = raw as? [String: Any],
1993
+ let replay = value["replay"] as? NSNumber,
1994
+ CFGetTypeID(replay) != CFBooleanGetTypeID(),
1995
+ replay.doubleValue.isFinite, replay.doubleValue >= 0 else { return nil }
1996
+ self.replay = replay.doubleValue
1997
+ enabled = value["enabled"] as? Bool != false
1998
+ }
1999
+ }
2000
+
2001
+ final class CraftNativeCelebration {
2002
+ private weak var view: UIView?
2003
+ private var request: CraftNativeCelebrationSpec?
2004
+ private var pending = false
2005
+ private var overlay: CALayer?
2006
+ private var sheen: CALayer?
2007
+ private var cleanup: DispatchWorkItem?
2008
+ private var observers: [NSObjectProtocol] = []
2009
+ private(set) var starts = 0
2010
+ var isRunning: Bool { overlay != nil }
2011
+
2012
+ init(view: UIView) {
2013
+ self.view = view
2014
+ for name in [UIApplication.didEnterBackgroundNotification, UIAccessibility.reduceMotionStatusDidChangeNotification] {
2015
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.stop() })
2016
+ }
2017
+ }
2018
+
2019
+ deinit {
2020
+ cleanup?.cancel()
2021
+ overlay?.removeFromSuperlayer()
2022
+ sheen?.removeFromSuperlayer()
2023
+ observers.forEach(NotificationCenter.default.removeObserver)
2024
+ }
2025
+
2026
+ func configure(_ raw: Any?) {
2027
+ let next = CraftNativeCelebrationSpec(raw)
2028
+ guard next != request else { return }
2029
+ stop()
2030
+ request = next
2031
+ pending = next?.enabled == true
2032
+ view?.setNeedsLayout()
2033
+ }
2034
+
2035
+ func stop() {
2036
+ pending = false
2037
+ cleanup?.cancel()
2038
+ cleanup = nil
2039
+ view?.layer.removeAnimation(forKey: "craft.celebration.entrance")
2040
+ overlay?.removeFromSuperlayer()
2041
+ overlay = nil
2042
+ sheen?.removeFromSuperlayer()
2043
+ sheen = nil
2044
+ }
2045
+
2046
+ func layout(reduceMotion: Bool = UIAccessibility.isReduceMotionEnabled) {
2047
+ guard pending, let view, let window = view.window,
2048
+ view.bounds.width > 0, view.bounds.height > 0, !view.isHidden else { return }
2049
+ var ancestor: UIView? = view.superview
2050
+ while let parent = ancestor {
2051
+ if parent.isHidden { return }
2052
+ ancestor = parent.superview
2053
+ }
2054
+ pending = false
2055
+ guard !reduceMotion else { return }
2056
+ starts += 1
2057
+ let frame = view.convert(view.bounds, to: window)
2058
+ let overlay = CALayer()
2059
+ overlay.name = "craft.celebration.confetti"
2060
+ overlay.frame = window.bounds
2061
+ window.layer.addSublayer(overlay)
2062
+ self.overlay = overlay
2063
+
2064
+ // Core Animation owns every frame. No script timers, per-frame bridge
2065
+ // messages, bitmap assets or touch-intercepting overlay views.
2066
+ let colors: [UIColor] = [.init(red: 0.23, green: 0.51, blue: 0.96, alpha: 1), .init(red: 0.6, green: 0.96, blue: 0.89, alpha: 1), .init(red: 0.9, green: 0.77, blue: 0.55, alpha: 1), .white]
2067
+ let now = CACurrentMediaTime()
2068
+ for index in 0..<52 {
2069
+ let side: CGFloat = index % 2 == 0 ? -1 : 1
2070
+ let spread = CGFloat((index * 37) % 101) / 100
2071
+ let start = CGPoint(x: frame.midX + side * frame.width * 0.42, y: frame.minY + 28)
2072
+ let end = CGPoint(x: frame.midX + side * (35 + spread * window.bounds.width * 0.7), y: window.bounds.height + 30)
2073
+ let piece = CALayer()
2074
+ piece.bounds = CGRect(x: 0, y: 0, width: index % 3 == 0 ? 4 : 7, height: 11)
2075
+ piece.cornerRadius = 1
2076
+ piece.backgroundColor = colors[index % colors.count].cgColor
2077
+ piece.position = end
2078
+ piece.opacity = 0
2079
+ overlay.addSublayer(piece)
2080
+ let path = CAKeyframeAnimation(keyPath: "position")
2081
+ path.values = [NSValue(cgPoint: start), NSValue(cgPoint: CGPoint(x: start.x + side * (45 + spread * 65), y: max(0, start.y - 70 - spread * 100))), NSValue(cgPoint: end)]
2082
+ path.keyTimes = [0, 0.28, 1]
2083
+ path.calculationMode = .cubic
2084
+ let tumble = CABasicAnimation(keyPath: "transform.rotation")
2085
+ tumble.fromValue = side * Double(index)
2086
+ tumble.toValue = side * (Double(index) + 7 + Double(spread) * 10)
2087
+ let fade = CAKeyframeAnimation(keyPath: "opacity")
2088
+ fade.values = [0, 1, 1, 0]
2089
+ fade.keyTimes = [0, 0.04, 0.7, 1]
2090
+ let group = CAAnimationGroup()
2091
+ group.animations = [path, tumble, fade]
2092
+ group.duration = 2.2 + Double(spread) * 0.45
2093
+ group.beginTime = now + Double(index % 7) * 0.018
2094
+ group.fillMode = .backwards
2095
+ piece.add(group, forKey: "craft.celebration.particle")
2096
+ }
2097
+
2098
+ let entrance = CASpringAnimation(keyPath: "transform")
2099
+ var from = CATransform3DMakeTranslation(0, 34, 0)
2100
+ from = CATransform3DRotate(from, -0.045, 0, 0, 1)
2101
+ from = CATransform3DScale(from, 0.86, 0.86, 1)
2102
+ entrance.fromValue = NSValue(caTransform3D: CATransform3DConcat(view.layer.transform, from))
2103
+ entrance.toValue = NSValue(caTransform3D: view.layer.transform)
2104
+ entrance.damping = 19
2105
+ entrance.stiffness = 160
2106
+ entrance.mass = 1
2107
+ entrance.duration = 0.95
2108
+ view.layer.add(entrance, forKey: "craft.celebration.entrance")
2109
+
2110
+ let sheen = CAGradientLayer()
2111
+ sheen.name = "craft.celebration.sheen"
2112
+ sheen.frame = view.bounds
2113
+ sheen.colors = [UIColor.clear.cgColor, UIColor.white.withAlphaComponent(0.2).cgColor, UIColor.clear.cgColor]
2114
+ sheen.locations = [0.25, 0.5, 0.75]
2115
+ sheen.startPoint = CGPoint(x: 0, y: 0.3)
2116
+ sheen.endPoint = CGPoint(x: 1, y: 0.7)
2117
+ sheen.opacity = 0
2118
+ let mask = CAShapeLayer()
2119
+ mask.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: view.layer.cornerRadius).cgPath
2120
+ sheen.mask = mask
2121
+ view.layer.addSublayer(sheen)
2122
+ self.sheen = sheen
2123
+ let sweep = CABasicAnimation(keyPath: "transform.translation.x")
2124
+ sweep.fromValue = -view.bounds.width
2125
+ sweep.toValue = view.bounds.width
2126
+ let light = CAKeyframeAnimation(keyPath: "opacity")
2127
+ light.values = [0, 1, 1, 0]
2128
+ light.keyTimes = [0, 0.15, 0.8, 1]
2129
+ let shine = CAAnimationGroup()
2130
+ shine.animations = [sweep, light]
2131
+ shine.duration = 1.15
2132
+ shine.beginTime = now + 0.55
2133
+ sheen.add(shine, forKey: "craft.celebration.sweep")
2134
+ let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
2135
+ self.cleanup = cleanup
2136
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: cleanup)
2137
+ }
2138
+ }
2139
+
1837
2140
  private class CraftNativeFlowView: UIStackView {
2141
+ var celebration: CraftNativeCelebration?
2142
+ func configureCelebration(_ raw: Any?) {
2143
+ if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
2144
+ celebration?.configure(raw)
2145
+ }
2146
+ override func didMoveToWindow() {
2147
+ super.didMoveToWindow()
2148
+ if window == nil { celebration?.stop() }
2149
+ else { setNeedsLayout() }
2150
+ }
1838
2151
  var pressActiveOpacity: CGFloat?
1839
2152
  var pressBaseOpacity: CGFloat = 1
1840
2153
  var pressUnderlayColor: UIColor?
@@ -2051,6 +2364,7 @@ private class CraftNativeFlowView: UIStackView {
2051
2364
  override func layoutSubviews() {
2052
2365
  super.layoutSubviews()
2053
2366
  craftApplyCornerRadius()
2367
+ celebration?.layout()
2054
2368
  let scale = window?.screen.scale ?? traitCollection.displayScale
2055
2369
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
2056
2370
  var moved = false
@@ -4134,6 +4448,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4134
4448
 
4135
4449
  override func viewWillDisappear(_ animated: Bool) {
4136
4450
  super.viewWillDisappear(animated)
4451
+ stopCelebrations(in: view)
4137
4452
  // The accessory belongs to the visible native screen. Clear the
4138
4453
  // shared shell state before a hybrid/web route appears, otherwise a
4139
4454
  // destination from the outgoing screen remains tappable on top of it.
@@ -4188,6 +4503,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4188
4503
  /// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
4189
4504
  func screenVisibilityChanged(_ visible: Bool) {
4190
4505
  guard !runtimeDisposed else { return }
4506
+ if !visible, isViewLoaded { stopCelebrations(in: view) }
4191
4507
  // Hidden tab stacks receive no UIKit appearance callback. A cancelled
4192
4508
  // preview also restores a screen that never lost focus, so restore its
4193
4509
  // chrome even when no new SCREEN_FOCUS event is needed.
@@ -6130,6 +6446,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6130
6446
  case "Map":
6131
6447
  let map = result as! CraftNativeMapView
6132
6448
  let mapMarkerHandler = nonEmptyHandler(events["onMarkerPress"])
6449
+ map.onMapPress = { [weak self] coordinate in
6450
+ guard let self, let handler = self.nonEmptyHandler(events["onPress"]) else { return }
6451
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["latitude": coordinate.latitude, "longitude": coordinate.longitude]])
6452
+ }
6453
+ map.onRegionChangeComplete = { [weak self] region, heading, pitch in
6454
+ guard let self, let handler = self.nonEmptyHandler(events["onRegionChangeComplete"]) else { return }
6455
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [
6456
+ "center": ["latitude": region.center.latitude, "longitude": region.center.longitude],
6457
+ "span": ["latitudeDelta": region.span.latitudeDelta, "longitudeDelta": region.span.longitudeDelta],
6458
+ "heading": heading, "pitch": pitch,
6459
+ ]])
6460
+ }
6133
6461
  map.configure(props: props, children: children) { [weak self, weak map] markerID in
6134
6462
  guard let self, let map,
6135
6463
  let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
@@ -6217,9 +6545,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6217
6545
  if disabled { result.isUserInteractionEnabled = false }
6218
6546
  }
6219
6547
  applyAccessibility(props, type: type, to: result)
6548
+ (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
6220
6549
  return current
6221
6550
  }
6222
6551
 
6552
+ private func stopCelebrations(in root: UIView) {
6553
+ (root as? CraftNativeFlowView)?.celebration?.stop()
6554
+ root.subviews.forEach { stopCelebrations(in: $0) }
6555
+ }
6556
+
6223
6557
  private func makeView(_ type: String, props: [String: Any]) -> UIView {
6224
6558
  switch type {
6225
6559
  case "Text":