@craft-native/ios 0.0.293 → 0.0.299

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
@@ -157,7 +157,7 @@ and linear gradients are applied by UIKit with the same style keys as Android;
157
157
  `elevation` remains Android-specific. Gradient layers follow the native view's
158
158
  bounds and corners through layout changes.
159
159
 
160
- The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
160
+ The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
161
161
 
162
162
  `FlatList` rows are authored as keyed `:for` child rows in native STX; the
163
163
  maintained protocol does not serialize `data`/`renderItem` callbacks.
@@ -391,6 +391,29 @@ Appearance and navigation:
391
391
  - `swipeNavigation`: an edge swipe goes back and forward through the page's
392
392
  history, pushState entries included.
393
393
 
394
+ Native tab-bar selection styling is opt-in and lives in the generated config,
395
+ so the generated Swift never needs to be patched:
396
+
397
+ ```json
398
+ {
399
+ "tabBar": {
400
+ "selection": { "cornerRadius": 8, "color": "#123456" }
401
+ },
402
+ "postGenerate": {
403
+ "command": "bun",
404
+ "args": ["run", "scripts/customize-native.ts"]
405
+ }
406
+ }
407
+ ```
408
+
409
+ `cornerRadius` is measured in points and `color` is `#RRGGBB`; omit either to
410
+ keep its host default. `postGenerate` runs after `craft ios build` has written
411
+ the Xcode project, with the generated project as its working directory. Craft
412
+ does not invoke a shell: `command` and `args` are passed directly. The hook
413
+ receives `CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM=ios`, and
414
+ `CRAFT_GENERATE_PHASE=build`. A missing executable, non-zero exit, or invalid
415
+ configuration fails the build with the hook's stderr in the diagnostic.
416
+
394
417
  Debug builds let Safari's Develop menu inspect the webview, and relay the
395
418
  page's console, uncaught errors, rejected promises and failed script loads to
396
419
  the device log as `[craft page] …`, so a page that only breaks inside the app
@@ -1282,3 +1305,22 @@ nativeFonts: {
1282
1305
 
1283
1306
  The iOS project registers the files through `UIAppFonts`. Android loads bundled
1284
1307
  typefaces from assets. Both work offline; a missing file fails the build.
1308
+
1309
+ ### Platform selection, clear fields and sheets
1310
+
1311
+ `SegmentedControl` takes `items: [{value, label, disabled?}]` and a controlled
1312
+ `value`, and emits `onValueChange` with `{value, index}`. iOS uses UIKit's
1313
+ `UISegmentedControl`; Android uses a horizontal native `RadioGroup` with
1314
+ selected segments. Reconciliation does not emit a user selection.
1315
+
1316
+ `TextInput.clearButtonMode` is `never`, `while-editing`, `unless-editing` or
1317
+ `always`. The native clear affordance emits a single text edit carrying
1318
+ `{text: '', clear: true}`; Android also exposes a TalkBack clear action.
1319
+
1320
+ `Modal` accepts `presentationStyle: 'pageSheet' | 'formSheet'` with
1321
+ `sheetDetents: ['medium', 'large']`, `selectedDetent` and `grabberVisible`.
1322
+ iOS presents a real `UISheetPresentationController`, including system pan
1323
+ and detents; Android sizes its native bottom panel to the selected detent
1324
+ without an extra dependency. Omitting presentationStyle keeps the existing
1325
+ full-screen overlay. A native sheet's close request still belongs to its
1326
+ owner, which sets visible to false.
package/dist/cli.js CHANGED
@@ -325,6 +325,30 @@ function installNativeBundle(source, output) {
325
325
  function hasInstalledNativeBundle(output) {
326
326
  return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
327
327
  }
328
+ function runPostGenerateHook(hook, output, phase = "build") {
329
+ if (!hook)
330
+ return;
331
+ if (typeof hook.command !== "string" || !hook.command.trim())
332
+ throw new Error("iOS postGenerate.command must be a non-empty executable");
333
+ if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
334
+ throw new Error("iOS postGenerate.args must be an array of strings");
335
+ const command = [hook.command, ...hook.args ?? []];
336
+ let result;
337
+ try {
338
+ result = Bun.spawnSync(command, {
339
+ cwd: output,
340
+ env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
341
+ stdout: "inherit",
342
+ stderr: "pipe"
343
+ });
344
+ } catch (error) {
345
+ throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
346
+ }
347
+ if (result.exitCode !== 0) {
348
+ const stderr = result.stderr?.toString().trim();
349
+ throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
350
+ }
351
+ }
328
352
  var DEFAULT_CONFIG = {
329
353
  renderer: "web",
330
354
  version: "1.0.0",
@@ -1030,6 +1054,11 @@ async function init(options) {
1030
1054
  if (config.renderer !== "web" && config.renderer !== "native") {
1031
1055
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1032
1056
  }
1057
+ const selection = config.tabBar?.selection;
1058
+ if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1059
+ throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
1060
+ if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
1061
+ throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
1033
1062
  if (config.enableBackgroundLocation)
1034
1063
  config.enableGeolocation = true;
1035
1064
  const problems = hybridConfigProblems(config);
@@ -1225,6 +1254,7 @@ async function build(options) {
1225
1254
  cause: error
1226
1255
  });
1227
1256
  }
1257
+ runPostGenerateHook(config.postGenerate, output);
1228
1258
  console.log("");
1229
1259
  }
1230
1260
  async function open(options) {
package/dist/index.d.ts CHANGED
@@ -9,6 +9,19 @@ export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], outpu
9
9
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
10
10
  export declare function resolveCraftVersion(startDir?: string): string;
11
11
  export type CraftAppearance = 'light' | 'dark' | 'system';
12
+ /** A declared command Craft runs after the native project has been generated. */
13
+ export interface CraftPostGenerateHook {
14
+ command: string;
15
+ args?: string[];
16
+ }
17
+ export interface CraftTabBarConfig {
18
+ selection?: {
19
+ /** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
20
+ cornerRadius?: number;
21
+ /** The selected tab indicator fill as #RRGGBB. */
22
+ color?: string;
23
+ };
24
+ }
12
25
  export interface CraftConfig {
13
26
  appShortcuts?: CraftAppShortcut[];
14
27
  /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -29,6 +42,10 @@ export interface CraftConfig {
29
42
  * and `dark` pin one. Unset keeps the older `darkMode` switch.
30
43
  */
31
44
  appearance?: CraftAppearance;
45
+ /** Static native tab-bar styling applied before the page's own tabBar.set call. */
46
+ tabBar?: CraftTabBarConfig;
47
+ /** Runs from the generated project root after `craft ios build` writes it. */
48
+ postGenerate?: CraftPostGenerateHook;
32
49
  backgroundColor?: string;
33
50
  /** The launch and webview background while the phone is in Dark Mode. */
34
51
  backgroundColorDark?: string;
@@ -409,6 +426,13 @@ export interface BuildOptions {
409
426
  */
410
427
  runtimeDir?: string | null;
411
428
  }
429
+ /**
430
+ * Run the app's declared generator hook without invoking a shell.
431
+ *
432
+ * A hook gets the generated project as its cwd and explicit metadata so a
433
+ * patch can target the right platform and phase without guessing from paths.
434
+ */
435
+ export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
412
436
  export interface OpenOptions {
413
437
  output: string;
414
438
  }
package/dist/index.js CHANGED
@@ -512,6 +512,30 @@ function installNativeBundle(source, output) {
512
512
  function hasInstalledNativeBundle(output) {
513
513
  return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
514
514
  }
515
+ function runPostGenerateHook(hook, output, phase = "build") {
516
+ if (!hook)
517
+ return;
518
+ if (typeof hook.command !== "string" || !hook.command.trim())
519
+ throw new Error("iOS postGenerate.command must be a non-empty executable");
520
+ if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
521
+ throw new Error("iOS postGenerate.args must be an array of strings");
522
+ const command = [hook.command, ...hook.args ?? []];
523
+ let result;
524
+ try {
525
+ result = Bun.spawnSync(command, {
526
+ cwd: output,
527
+ env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
528
+ stdout: "inherit",
529
+ stderr: "pipe"
530
+ });
531
+ } catch (error) {
532
+ throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
533
+ }
534
+ if (result.exitCode !== 0) {
535
+ const stderr = result.stderr?.toString().trim();
536
+ throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
537
+ }
538
+ }
515
539
  var DEFAULT_CONFIG = {
516
540
  renderer: "web",
517
541
  version: "1.0.0",
@@ -1230,6 +1254,11 @@ async function init(options) {
1230
1254
  if (config.renderer !== "web" && config.renderer !== "native") {
1231
1255
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1232
1256
  }
1257
+ const selection = config.tabBar?.selection;
1258
+ if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1259
+ throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
1260
+ if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
1261
+ throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
1233
1262
  if (config.enableBackgroundLocation)
1234
1263
  config.enableGeolocation = true;
1235
1264
  const problems = hybridConfigProblems(config);
@@ -1425,6 +1454,7 @@ async function build(options) {
1425
1454
  cause: error
1426
1455
  });
1427
1456
  }
1457
+ runPostGenerateHook(config.postGenerate, output);
1428
1458
  console.log("");
1429
1459
  }
1430
1460
  async function open(options) {
@@ -1590,6 +1620,7 @@ export {
1590
1620
  resolveRuntimeDir,
1591
1621
  restoreShell,
1592
1622
  run,
1623
+ runPostGenerateHook,
1593
1624
  serializeSimulatorSteps,
1594
1625
  showSimulator,
1595
1626
  syncNativeFonts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.293",
3
+ "version": "0.0.299",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
102
102
  }
103
103
  }
104
104
 
105
+ /// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
106
+ /// only controls the SwiftUI environment; native screen controllers need the
107
+ /// UIKit override as well so their trait collection, controls and navigation
108
+ /// chrome resolve the same way. A runtime choice is kept here so screens
109
+ /// created after the choice inherit it instead of reverting to the bundle
110
+ /// config.
111
+ enum CraftInterfaceStyleController {
112
+ /// The app's bundled choice is the default for newly-created screens.
113
+ /// A runtime choice is separate: it is the only value that should
114
+ /// override a screen's own generated config.
115
+ private static var configured: UIUserInterfaceStyle = .unspecified
116
+ private static var runtimeSelection: UIUserInterfaceStyle?
117
+
118
+ static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
119
+ switch config.appearance {
120
+ case "system": return .unspecified
121
+ case "light": return .light
122
+ case "dark": return .dark
123
+ default: return config.darkMode ? .dark : .light
124
+ }
125
+ }
126
+
127
+ static func configure(_ config: CraftConfig) {
128
+ configured = configuredStyle(for: config)
129
+ runtimeSelection = nil
130
+ applyToWindows()
131
+ }
132
+
133
+ static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
134
+ runtimeSelection ?? configuredStyle(for: config)
135
+ }
136
+
137
+ static func colorScheme(for config: CraftConfig) -> ColorScheme? {
138
+ switch style(for: config) {
139
+ case .light: return .light
140
+ case .dark: return .dark
141
+ default: return nil
142
+ }
143
+ }
144
+
145
+ /// Returns false rather than silently accepting a typo from JavaScript.
146
+ @discardableResult
147
+ static func set(_ value: String) -> Bool {
148
+ let style: UIUserInterfaceStyle
149
+ switch value {
150
+ case "system": style = .unspecified
151
+ case "light": style = .light
152
+ case "dark": style = .dark
153
+ default: return false
154
+ }
155
+ runtimeSelection = style
156
+ applyToWindows()
157
+ NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
158
+ return true
159
+ }
160
+
161
+ static func currentName() -> String {
162
+ let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
163
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
164
+ .first ?? .unspecified
165
+ let style = requested == .unspecified
166
+ ? (UIApplication.shared.connectedScenes
167
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
168
+ .first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
169
+ : requested
170
+ return style == .dark ? "dark" : "light"
171
+ }
172
+
173
+ static func apply(to controller: UIViewController, config: CraftConfig) {
174
+ controller.overrideUserInterfaceStyle = style(for: config)
175
+ }
176
+
177
+ private static func applyToWindows() {
178
+ let style = runtimeSelection ?? configured
179
+ for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
180
+ for window in scene.windows {
181
+ window.overrideUserInterfaceStyle = style
182
+ apply(style, to: window.rootViewController)
183
+ }
184
+ }
185
+ }
186
+
187
+ private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
188
+ guard let controller else { return }
189
+ controller.overrideUserInterfaceStyle = style
190
+ controller.children.forEach { apply(style, to: $0) }
191
+ if let presented = controller.presentedViewController { apply(style, to: presented) }
192
+ }
193
+ }
194
+
105
195
  /// Tells the shell when SwiftUI's colour scheme changes: the one appearance
106
196
  /// setting with no notification of its own.
107
197
  struct CraftColorSchemeObserver: View {
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
195
285
  final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
196
286
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
197
287
  let config = CraftConfig.bundled()
288
+ CraftInterfaceStyleController.configure(config)
198
289
  // First, so a crash anywhere in the rest of launch is reported too.
199
290
  CraftCrashReporter.shared.start(config)
200
291
  UNUserNotificationCenter.current().delegate = self
@@ -606,7 +697,7 @@ struct CraftApp: App {
606
697
  .ignoresSafeArea(.keyboard, edges: .bottom)
607
698
  }
608
699
  }
609
- .preferredColorScheme(appState.config.colorScheme)
700
+ .preferredColorScheme(appState.appearanceScheme)
610
701
  .environmentObject(appState)
611
702
  .onOpenURL { url in
612
703
  // Handle deep links and universal links
@@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject {
689
780
  /// The selected tab highlight's corner radius. Nil preserves the default
690
781
  /// capsule, while a finite non-negative value opts into a rounded rect.
691
782
  private(set) var tabSelectionCornerRadius: CGFloat?
783
+ /// The configured static shape, retained when the page updates only its
784
+ /// tabs or selected item at runtime.
785
+ private var configuredTabSelectionCornerRadius: CGFloat?
786
+ private(set) var tabSelectionColor: UIColor?
692
787
 
693
788
  weak var webView: WKWebView?
694
789
  /// What the tab bar covers at the bottom of the screen, in points.
@@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject {
723
818
  /// The tab bar from `tabs` in craft.config.json, there before the page
724
819
  /// loads; the page's own `tabBar` message replaces it later. `splash`
725
820
  /// false when the first screen is native and draws itself.
726
- func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
821
+ func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
727
822
  splashVisible = splash
728
823
  tabSwipeEnabled = true
824
+ configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
825
+ .flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
826
+ tabSelectionCornerRadius = configuredTabSelectionCornerRadius
827
+ tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
729
828
  guard !configured.isEmpty else { return }
730
829
  tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
731
830
  self.selected = selected ?? configured.first?.id
@@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject {
856
955
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
857
956
  }
858
957
  if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
958
+ if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
859
959
  withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
860
960
  publishLayout()
861
961
  case "selectTab":
@@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape {
1312
1412
  }
1313
1413
 
1314
1414
  private struct CraftTabSelectionIndicator: View {
1415
+ @ObservedObject private var chrome = CraftChrome.shared
1315
1416
  @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1316
1417
  let selected: Int?
1317
1418
  let count: Int
@@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View {
1323
1424
  let width = geometry.size.width / CGFloat(max(1, count))
1324
1425
  CraftTabSelectionShape(cornerRadius: cornerRadius)
1325
1426
  .frame(width: width, height: geometry.size.height)
1326
- .foregroundStyle(Color.primary.opacity(0.09))
1427
+ .foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
1327
1428
  .offset(x: position * width)
1328
1429
  }
1329
1430
  }
@@ -1557,6 +1658,8 @@ class DeepLinkManager {
1557
1658
  // MARK: - App State
1558
1659
  class AppState: ObservableObject {
1559
1660
  @Published var config: CraftConfig
1661
+ @Published private(set) var appearanceScheme: ColorScheme?
1662
+ private var appearanceObserver: NSObjectProtocol?
1560
1663
 
1561
1664
  init() {
1562
1665
  if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
@@ -1565,11 +1668,26 @@ class AppState: ObservableObject {
1565
1668
  } else {
1566
1669
  self.config = CraftConfig()
1567
1670
  }
1671
+ self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config)
1672
+ appearanceObserver = NotificationCenter.default.addObserver(
1673
+ forName: .craftColorSchemeChanged,
1674
+ object: nil,
1675
+ queue: .main,
1676
+ ) { [weak self] _ in
1677
+ guard let self else { return }
1678
+ self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config)
1679
+ }
1568
1680
  // Before the first frame: the hybrid start screen, and the tab bar
1569
1681
  // from the config, are decided here, if the app delegate has not
1570
1682
  // already (it does at launch; the first call decides).
1571
1683
  CraftHybrid.shared.configure(config)
1572
1684
  }
1685
+
1686
+ deinit {
1687
+ if let appearanceObserver {
1688
+ NotificationCenter.default.removeObserver(appearanceObserver)
1689
+ }
1690
+ }
1573
1691
  }
1574
1692
 
1575
1693
  extension CraftConfig {
@@ -1706,6 +1824,9 @@ struct CraftConfig: Codable {
1706
1824
  var nativeScreens: [String: String]? = nil
1707
1825
  /// The tab bar to show before the page describes its own.
1708
1826
  var tabs: [Tab]? = nil
1827
+ /// Static styling for the selected tab indicator; the page may still
1828
+ /// override its shape through `tabBar.set({ selection })`.
1829
+ var tabBar: TabBar? = nil
1709
1830
  /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1710
1831
  /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1711
1832
  var shareStorage: [String: String]? = nil
@@ -1733,6 +1854,15 @@ struct CraftConfig: Codable {
1733
1854
  var selectedIconPulse: Bool? = nil
1734
1855
  }
1735
1856
 
1857
+ struct TabBar: Codable {
1858
+ var selection: TabSelection? = nil
1859
+ }
1860
+
1861
+ struct TabSelection: Codable {
1862
+ var cornerRadius: Double? = nil
1863
+ var color: String? = nil
1864
+ }
1865
+
1736
1866
  struct BackgroundRefresh: Codable {
1737
1867
  var enabled: Bool = false
1738
1868
  /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
@@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable {
2535
2665
  resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
2536
2666
  case "statusBarSetStyle":
2537
2667
  resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
2668
+ case "setAppearance":
2669
+ guard let appearance = body["appearance"] as? String else {
2670
+ rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2671
+ return
2672
+ }
2673
+ guard CraftInterfaceStyleController.set(appearance) else {
2674
+ rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2675
+ return
2676
+ }
2677
+ resolveCallback(callbackId, result: true)
2678
+ case "getAppearance":
2679
+ resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
2538
2680
  case "chromeSetUnderPageColor":
2539
2681
  // What shows past the page's edges when it rubber-bands.
2540
2682
  if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
@@ -5596,6 +5738,14 @@ struct CraftWebView: UIViewRepresentable {
5596
5738
  getInfo: function() { return craft.getDeviceInfo(); },
5597
5739
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
5598
5740
  };
5741
+ craft.app = Object.assign(craft.app || {}, {
5742
+ setAppearance: function(appearance) {
5743
+ return craft._invoke('setAppearance', {appearance: appearance});
5744
+ },
5745
+ getAppearance: function() {
5746
+ return craft._invoke('getAppearance', {});
5747
+ }
5748
+ });
5599
5749
  // Feedback, as on Android and the web, where there is no
5600
5750
  // motor to fire: an app that left enableHaptics off gets
5601
5751
  // nothing played and a settled promise, so `await
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
380
380
  }
381
381
  startsNative = isEnabled && routes.match(startPath) != nil
382
382
  // The splash waits for a page; an all-native app's first screen draws itself.
383
- CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
383
+ CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative, appearance: config.tabBar)
384
384
 
385
385
  let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
386
386
  appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
@@ -42,7 +42,7 @@ enum CraftNativeActions {
42
42
  static func capabilities(config: CraftConfig) -> [String] {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
46
46
  values.append("storageCompareAndSet")
47
47
  values.append("storageSetIfAbsent")
48
48
  values.append("viewScroll")
@@ -2578,6 +2578,7 @@ extension UIView {
2578
2578
  var responder: UIResponder? = next
2579
2579
  while let current = responder {
2580
2580
  if let screen = current as? CraftNativeScreenController { return screen }
2581
+ if let sheet = current as? CraftNativeSheetController { return sheet.screen }
2581
2582
  responder = current.next
2582
2583
  }
2583
2584
  return nil
@@ -2983,7 +2984,10 @@ private class CraftNativeFlowView: UIStackView {
2983
2984
  // The screen reports onLayout from its own layout pass, which a
2984
2985
  // change deep in a scroll view's content never runs: a view that
2985
2986
  // went from 240 to 180 wide kept saying 240.
2986
- if moved { craftScreenController?.scheduleLayoutEvents() }
2987
+ if moved {
2988
+ craftScreenController?.scheduleLayoutEvents()
2989
+ craftScreenController?.scheduleKeyboardInsets()
2990
+ }
2987
2991
  }
2988
2992
  for (child, frame) in result.frames {
2989
2993
  var viewportFrame = frame
@@ -3973,13 +3977,141 @@ private final class CraftNativeImageBackgroundView: UIImageView {
3973
3977
  }
3974
3978
  }
3975
3979
 
3976
- private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3980
+ private final class CraftNativeSegmentElement: UIAccessibilityElement {
3981
+ weak var control: CraftNativeSegmentedControl?
3982
+ let index: Int
3983
+ init(control: CraftNativeSegmentedControl, index: Int) {
3984
+ self.control = control
3985
+ self.index = index
3986
+ super.init(accessibilityContainer: control)
3987
+ }
3988
+ override func accessibilityActivate() -> Bool {
3989
+ guard let control, control.isEnabled, control.values.indices.contains(index), control.isEnabledForSegment(at: index) else { return false }
3990
+ if control.selectedSegmentIndex != index {
3991
+ control.selectedSegmentIndex = index
3992
+ control.sendActions(for: .valueChanged)
3993
+ }
3994
+ return true
3995
+ }
3996
+ }
3997
+
3998
+ private final class CraftNativeSegmentedControl: UISegmentedControl {
3999
+ var values: [String] = []
4000
+ private var segments: [CraftNativeSegmentElement] = []
4001
+ override var accessibilityElements: [Any]? {
4002
+ get { segments }
4003
+ set { /* The native segments, not the labelled group, own activation. */ }
4004
+ }
4005
+ func configureAccessibility(items: [[String: Any]]) {
4006
+ if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
4007
+ for (index, item) in items.enumerated() {
4008
+ segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
4009
+ }
4010
+ refreshAccessibilityState()
4011
+ setNeedsLayout()
4012
+ }
4013
+ func refreshAccessibilityState() {
4014
+ for (index, segment) in segments.enumerated() {
4015
+ segment.accessibilityTraits = [.button]
4016
+ if selectedSegmentIndex == index { segment.accessibilityTraits.insert(.selected) }
4017
+ if !isEnabled || !isEnabledForSegment(at: index) { segment.accessibilityTraits.insert(.notEnabled) }
4018
+ }
4019
+ }
4020
+ override func layoutSubviews() {
4021
+ super.layoutSubviews()
4022
+ guard !segments.isEmpty else { return }
4023
+ let width = bounds.width / CGFloat(segments.count)
4024
+ for (index, segment) in segments.enumerated() {
4025
+ let visualIndex = effectiveUserInterfaceLayoutDirection == .rightToLeft ? segments.count - index - 1 : index
4026
+ segment.accessibilityFrameInContainerSpace = CGRect(x: CGFloat(visualIndex) * width, y: 0, width: width, height: bounds.height)
4027
+ }
4028
+ }
4029
+ }
4030
+
4031
+ private final class CraftNativeSheetController: UIViewController {
4032
+ weak var screen: CraftNativeScreenController?
4033
+ weak var content: CraftNativeFlowView?
4034
+ override func viewDidLayoutSubviews() {
4035
+ super.viewDidLayoutSubviews()
4036
+ content?.frame = view.bounds.inset(by: view.safeAreaInsets)
4037
+ }
4038
+ }
4039
+
4040
+ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, UISheetPresentationControllerDelegate {
3977
4041
  let contentStack = CraftNativeFlowView()
3978
4042
  private let blocker = UIView()
3979
4043
  private let backdropTap = UITapGestureRecognizer()
3980
4044
  private var renderedVisible = false
3981
4045
  private var animationGeneration = 0
3982
4046
  var onRequestClose: ((String) -> Void)?
4047
+ weak var sheetPresenter: CraftNativeScreenController?
4048
+ var presentationStyle = "overFullScreen"
4049
+ var sheetDetents: [String] = ["medium", "large"]
4050
+ var selectedDetent: String?
4051
+ var grabberVisible = true
4052
+ private var sheetController: CraftNativeSheetController?
4053
+ var isPresentedAsSheet: Bool { sheetController != nil }
4054
+
4055
+ private func configureSheet(_ controller: CraftNativeSheetController) {
4056
+ controller.isModalInPresentation = onRequestClose == nil
4057
+ guard let sheet = controller.sheetPresentationController else { return }
4058
+ let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
4059
+ let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
4060
+ sheet.detents = values.map { $0 == "medium" ? .medium() : .large() }
4061
+ sheet.prefersGrabberVisible = grabberVisible
4062
+ if let selectedDetent, values.contains(selectedDetent) {
4063
+ sheet.selectedDetentIdentifier = selectedDetent == "medium" ? .medium : .large
4064
+ }
4065
+ sheet.delegate = self
4066
+ }
4067
+
4068
+ private func restoreSheetContent(_ controller: CraftNativeSheetController) {
4069
+ guard sheetController === controller else { return }
4070
+ contentStack.removeFromSuperview()
4071
+ addSubview(contentStack)
4072
+ sheetController = nil
4073
+ setNeedsLayout()
4074
+ }
4075
+
4076
+ private func setSheetVisible(_ visible: Bool, animated: Bool) {
4077
+ renderedVisible = visible
4078
+ isHidden = true // UIKit owns the scrim, safe area, grabber and pan.
4079
+ guard visible else {
4080
+ guard let controller = sheetController else { return }
4081
+ controller.view.endEditing(true)
4082
+ controller.dismiss(animated: animated) { [weak self, weak controller] in
4083
+ guard let self, let controller else { return }
4084
+ self.restoreSheetContent(controller)
4085
+ if self.renderedVisible { self.setSheetVisible(true, animated: animated) }
4086
+ }
4087
+ return
4088
+ }
4089
+ if let controller = sheetController { configureSheet(controller); return }
4090
+ // A newly rendered modal is attached to its screen after this pass.
4091
+ DispatchQueue.main.async { [weak self] in
4092
+ guard let self, self.renderedVisible, self.sheetController == nil,
4093
+ let presenter = self.sheetPresenter, presenter.view.window != nil else { return }
4094
+ let controller = CraftNativeSheetController()
4095
+ controller.screen = presenter
4096
+ controller.modalPresentationStyle = self.presentationStyle == "formSheet" ? .formSheet : .pageSheet
4097
+ controller.view.backgroundColor = .systemBackground
4098
+ self.contentStack.removeFromSuperview()
4099
+ controller.view.addSubview(self.contentStack)
4100
+ controller.content = self.contentStack
4101
+ self.sheetController = controller
4102
+ self.configureSheet(controller)
4103
+ var top: UIViewController = presenter
4104
+ while let presented = top.presentedViewController { top = presented }
4105
+ top.present(controller, animated: animated)
4106
+ }
4107
+ }
4108
+
4109
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
4110
+ guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
4111
+ restoreSheetContent(controller)
4112
+ renderedVisible = false
4113
+ onRequestClose?("swipe")
4114
+ }
3983
4115
 
3984
4116
  var transparent = false {
3985
4117
  didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
@@ -4017,6 +4149,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4017
4149
  /// press bookkeeping once turned that off for a view with no handler of
4018
4150
  /// its own, and a shown modal let every tap through to the screen behind.
4019
4151
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
4152
+ if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
4153
+ setSheetVisible(visible, animated: animationType != "none")
4154
+ return
4155
+ }
4020
4156
  if visible { isUserInteractionEnabled = true }
4021
4157
  if renderedVisible == visible && isHidden == !visible {
4022
4158
  if visible, alpha != opacity { alpha = opacity }
@@ -4069,6 +4205,12 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4069
4205
  }
4070
4206
 
4071
4207
  func resetForTeardown() {
4208
+ renderedVisible = false
4209
+ sheetPresenter = nil
4210
+ if let controller = sheetController {
4211
+ controller.dismiss(animated: false)
4212
+ restoreSheetContent(controller)
4213
+ }
4072
4214
  craftScreenController?.forgetFocusedInput(in: self)
4073
4215
  endEditing(true)
4074
4216
  animationGeneration += 1
@@ -4130,6 +4272,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4130
4272
  override func layoutSubviews() {
4131
4273
  super.layoutSubviews()
4132
4274
  blocker.frame = bounds
4275
+ guard sheetController == nil else { return }
4133
4276
  // The scrim covers the whole screen; what the modal holds stays
4134
4277
  // clear of the bars and the home indicator it is drawn over.
4135
4278
  contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
@@ -5112,6 +5255,11 @@ private final class CraftNativeTextView: UITextView {
5112
5255
  isScrollEnabled = overflows
5113
5256
  if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
5114
5257
  }
5258
+ // Showing all its text, it has nothing to scroll. An offset left from
5259
+ // UIKit keeping the caret in view while the view was a layout short
5260
+ // of its text moved every line down inside it, and the caret of a
5261
+ // growing comment sat below the part of the screen it was kept in.
5262
+ if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
5115
5263
  let inset = textContainerInset
5116
5264
  let padding = textContainer.lineFragmentPadding
5117
5265
  placeholderLabel.frame = CGRect(
@@ -5492,6 +5640,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5492
5640
  private var keyboardSafeAreaInset: CGFloat = 0
5493
5641
  /// Where the keyboard is on the screen while it is up, in screen coordinates.
5494
5642
  private var keyboardScreenFrame: CGRect?
5643
+ private var keyboardInsetsScheduled = false
5495
5644
  /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
5496
5645
  /// app's native screens: told of the first frame, of a first frame that
5497
5646
  /// could not be drawn, and of `craft.navigation.open(path)`.
@@ -5536,6 +5685,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5536
5685
 
5537
5686
  override func viewDidLoad() {
5538
5687
  super.viewDidLoad()
5688
+ CraftInterfaceStyleController.apply(to: self, config: config)
5539
5689
  navigationItem.title = routeName ?? config.appName
5540
5690
  view.backgroundColor = config.resolvedBackgroundColor
5541
5691
  rootStack.axis = .vertical
@@ -5802,6 +5952,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5802
5952
  positionModals()
5803
5953
  emitLayoutEvents()
5804
5954
  sendViewportIfChanged()
5955
+ scheduleKeyboardInsets()
5805
5956
  }
5806
5957
 
5807
5958
  /// The width the bundle last heard, so a layout pass at the same size says nothing.
@@ -5884,7 +6035,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5884
6035
  @discardableResult
5885
6036
  func handleBackForModal() -> Bool {
5886
6037
  let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
5887
- !$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
6038
+ ($0.isPresentedAsSheet || (!$0.isHidden && $0.alpha > 0.01)) && ($0.blocksBackground || $0.dismissOnOutsideTap)
5888
6039
  }
5889
6040
  guard let modal else { return false }
5890
6041
  let hasHandler = modal.onRequestClose != nil
@@ -6240,6 +6391,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6240
6391
  let secureGet: @convention(block) (String) -> String? = { key in
6241
6392
  config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
6242
6393
  }
6394
+ let setAppearance: @convention(block) (String) -> Bool = { value in
6395
+ if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
6396
+ var accepted = false
6397
+ DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
6398
+ return accepted
6399
+ }
6400
+ let getAppearance: @convention(block) () -> String = {
6401
+ if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
6402
+ var value = "light"
6403
+ DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
6404
+ return value
6405
+ }
6243
6406
  let setOptions: @convention(block) (String) -> Void = { [weak self] json in
6244
6407
  guard let data = json.data(using: .utf8),
6245
6408
  let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
@@ -6252,6 +6415,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6252
6415
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
6253
6416
  jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
6254
6417
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
6418
+ jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
6419
+ jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
6255
6420
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
6256
6421
  // Older native bundles call the page-shaped `craft.dialog` API
6257
6422
  // directly. Keep that API backed by the same capability protocol as
@@ -6494,6 +6659,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6494
6659
  return value === undefined ? null : value;
6495
6660
  }
6496
6661
  });
6662
+ keep('app', {
6663
+ setAppearance: function(value) {
6664
+ value = String(value);
6665
+ if (value !== 'light' && value !== 'dark' && value !== 'system') {
6666
+ return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
6667
+ }
6668
+ if (!craftNativeSetAppearance(value)) {
6669
+ return Promise.reject(new Error('craft.app.setAppearance was rejected'));
6670
+ }
6671
+ return Promise.resolve();
6672
+ },
6673
+ getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
6674
+ });
6497
6675
  // The signed-in account (CraftSession): the token under
6498
6676
  // `sessionTokenKey`, and each change of it, which the host
6499
6677
  // reports through `__craftSessionChanged` after the Keychain
@@ -7148,6 +7326,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7148
7326
  }
7149
7327
  }
7150
7328
 
7329
+ /// A layout moved a view while the keyboard is up: once that pass is
7330
+ /// over, each scroll view's keyboard inset is measured again where the
7331
+ /// scroll view now ends. Measured only when the keyboard moved, a form
7332
+ /// that grew down under the keyboard (a footer below it shrank, a
7333
+ /// banner above it went away) kept the inset of where it used to end,
7334
+ /// and its last lines stayed under the keyboard.
7335
+ fileprivate func scheduleKeyboardInsets() {
7336
+ guard keyboardScreenFrame != nil, !keyboardInsetsScheduled else { return }
7337
+ keyboardInsetsScheduled = true
7338
+ DispatchQueue.main.async { [weak self] in
7339
+ guard let self else { return }
7340
+ self.keyboardInsetsScheduled = false
7341
+ if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
7342
+ }
7343
+ }
7344
+
7151
7345
  /// The focused field, scrolled into the part of its scroll view the
7152
7346
  /// keyboard leaves, with a little room around it.
7153
7347
  func scrollFocusedInputIntoView(animated: Bool) {
@@ -7159,6 +7353,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7159
7353
  /// Internal for XCTest.
7160
7354
  func scrollIntoView(_ input: UIView, animated: Bool) {
7161
7355
  guard let scroll = enclosingVerticalScroll(of: input) else { return }
7356
+ // Measured against the keyboard where the scroll view is now.
7357
+ if keyboardScreenFrame != nil { insetScrollViewsForKeyboard() }
7162
7358
  var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
7163
7359
  // A field taller than the part the keyboard leaves (a long comment
7164
7360
  // with no max height) shows its caret rather than its top: scrolled
@@ -7901,6 +8097,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7901
8097
  textMaxLengths.removeValue(forKey: fieldId)
7902
8098
  }
7903
8099
  field.delegate = self
8100
+ switch props["clearButtonMode"] as? String {
8101
+ case "while-editing": field.clearButtonMode = .whileEditing
8102
+ case "unless-editing": field.clearButtonMode = .unlessEditing
8103
+ case "always": field.clearButtonMode = .always
8104
+ default: field.clearButtonMode = .never
8105
+ }
7904
8106
  (field as? CraftNativeTextField)?.padding = paddingInsets(style)
7905
8107
  field.textColor = color(style["color"]) ?? .label
7906
8108
  field.font = textFont(style, default: .systemFont(ofSize: 16))
@@ -7921,7 +8123,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7921
8123
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
7922
8124
  applyAutofill(to: field, props: props)
7923
8125
  if let value = props["value"] as? String {
7924
- updateField(field, value: value)
8126
+ updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
7925
8127
  } else if previous?.view !== field {
7926
8128
  updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
7927
8129
  }
@@ -7933,6 +8135,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7933
8135
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
7934
8136
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
7935
8137
  }
8138
+ case "SegmentedControl":
8139
+ let control = result as! CraftNativeSegmentedControl
8140
+ let items = props["items"] as? [[String: Any]] ?? []
8141
+ let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
8142
+ // UIKit owns the segments; only rebuild when the choices change.
8143
+ // Setting a controlled value never dispatches valueChanged.
8144
+ if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
8145
+ control.removeAllSegments()
8146
+ for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
8147
+ }
8148
+ control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
8149
+ for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
8150
+ let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
8151
+ control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
8152
+ control.isEnabled = props["disabled"] as? Bool != true
8153
+ control.selectedSegmentTintColor = color(props["tintColor"])
8154
+ let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
8155
+ control.setTitleTextAttributes(titleAttributes, for: .normal)
8156
+ control.setTitleTextAttributes(titleAttributes, for: .selected)
8157
+ control.configureAccessibility(items: items)
8158
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
7936
8159
  case "Picker":
7937
8160
  let picker = result as! UIPickerView
7938
8161
  let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
@@ -7995,6 +8218,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7995
8218
  case "Modal":
7996
8219
  let modal = result as! CraftNativeModalView
7997
8220
  let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
8221
+ modal.sheetPresenter = self
8222
+ modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
8223
+ modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
8224
+ modal.selectedDetent = props["selectedDetent"] as? String
8225
+ modal.grabberVisible = props["grabberVisible"] as? Bool != false
8226
+ modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
8227
+ { [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
8228
+ }
7998
8229
  modal.transparent = props["transparent"] as? Bool == true
7999
8230
  modal.blocksBackground = props["nonModal"] as? Bool != true
8000
8231
  modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
@@ -8068,7 +8299,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8068
8299
  toggle.onTintColor = color(track["true"])
8069
8300
  toggle.tintColor = color(track["false"])
8070
8301
  } else {
8071
- toggle.onTintColor = nil
8302
+ toggle.onTintColor = color(style["color"])
8072
8303
  toggle.tintColor = nil
8073
8304
  }
8074
8305
  toggle.thumbTintColor = color(props["thumbColor"])
@@ -8106,7 +8337,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8106
8337
  }
8107
8338
  indicator.indicatorScale = scale
8108
8339
  applyTransform(style["transform"], to: indicator)
8109
- indicator.color = color(props["color"]) ?? .tintColor
8340
+ indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
8110
8341
  indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
8111
8342
  if props["animating"] as? Bool == false {
8112
8343
  indicator.stopAnimating()
@@ -8279,7 +8510,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8279
8510
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
8280
8511
  }
8281
8512
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
8282
- if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
8513
+ if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
8283
8514
  let disabled = props["disabled"] as? Bool == true
8284
8515
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
8285
8516
  updatePressFeedback(
@@ -8335,6 +8566,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8335
8566
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
8336
8567
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
8337
8568
  return field
8569
+ case "SegmentedControl":
8570
+ let control = CraftNativeSegmentedControl()
8571
+ control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
8572
+ return control
8338
8573
  case "Picker":
8339
8574
  let picker = UIPickerView()
8340
8575
  picker.dataSource = self
@@ -8401,6 +8636,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8401
8636
  case "link": return "Link"
8402
8637
  case "checkbox": return "Checkbox"
8403
8638
  case "textinput", "input": return "TextInput"
8639
+ case "segmentedcontrol", "segmented-control": return "SegmentedControl"
8404
8640
  case "picker", "select": return "Picker"
8405
8641
  case "datepicker", "date-picker", "date": return "DatePicker"
8406
8642
  case "modal": return "Modal"
@@ -8750,6 +8986,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8750
8986
  submitHandlers.removeValue(forKey: id)
8751
8987
  inputIdentities.removeValue(forKey: id)
8752
8988
  unechoedText.removeValue(forKey: id)
8989
+ inputEventCounts.removeValue(forKey: id)
8753
8990
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
8754
8991
  longPressHandlers.removeValue(forKey: id)
8755
8992
  textMaxLengths.removeValue(forKey: id)
@@ -8967,19 +9204,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8967
9204
  }
8968
9205
  }
8969
9206
 
8970
- /// Text a field sent its script that the script has not echoed back yet,
8971
- /// while the script runs on its own thread: a value that matches an older
8972
- /// one is the script catching up, not a new value, and applying it would
8973
- /// undo what was typed since.
9207
+ /// Legacy runtimes echo values without a revision. Keep their bounded
9208
+ /// history on either script thread; bridge delivery may still be delayed.
8974
9209
  private var unechoedText: [ObjectIdentifier: [String]] = [:]
9210
+ private var inputEventCounts: [ObjectIdentifier: Int] = [:]
8975
9211
 
8976
- private func noteSentText(_ text: String, for view: UIView) {
8977
- guard CraftNativeScriptThread.isBackground else { return }
9212
+ @discardableResult
9213
+ private func noteSentText(_ text: String, for view: UIView) -> Int {
8978
9214
  let id = ObjectIdentifier(view)
9215
+ let count = (inputEventCounts[id] ?? 0) + 1
9216
+ inputEventCounts[id] = count
8979
9217
  var sent = unechoedText[id] ?? []
8980
9218
  sent.append(text)
8981
9219
  if sent.count > 32 { sent.removeFirst(sent.count - 32) }
8982
9220
  unechoedText[id] = sent
9221
+ return count
8983
9222
  }
8984
9223
 
8985
9224
  /// Whether `value` is a stale echo of text the view has moved past: it
@@ -8997,9 +9236,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8997
9236
  return later > 0
8998
9237
  }
8999
9238
 
9000
- private func updateField(_ field: UITextField, value: String?) {
9239
+ private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
9240
+ if let acknowledged, acknowledged >= 0 {
9241
+ guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
9242
+ unechoedText.removeValue(forKey: ObjectIdentifier(view))
9243
+ return true // A current explicit reset may equal an older edit.
9244
+ }
9245
+ return !isStaleEcho(value, for: view)
9246
+ }
9247
+
9248
+ private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
9001
9249
  guard let value = value, field.markedTextRange == nil else { return }
9002
- if isStaleEcho(value, for: field) || field.text == value { return }
9250
+ if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
9003
9251
  let selection = field.selectedTextRange
9004
9252
  let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
9005
9253
  let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
@@ -9011,9 +9259,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9011
9259
  }
9012
9260
  }
9013
9261
 
9014
- private func updateTextView(_ textView: UITextView, value: String?) {
9262
+ private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
9015
9263
  guard let value = value, textView.markedTextRange == nil else { return }
9016
- if isStaleEcho(value, for: textView) || textView.text == value { return }
9264
+ if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
9017
9265
  let selection = textView.selectedTextRange
9018
9266
  let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
9019
9267
  let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
@@ -9076,7 +9324,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9076
9324
  craftTextView.placeholderColor = color(props["placeholderTextColor"])
9077
9325
  craftTextView.placeholderFont = textView.font
9078
9326
  }
9079
- updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
9327
+ updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
9080
9328
  if textView.markedTextRange == nil {
9081
9329
  let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
9082
9330
  let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
@@ -9780,7 +10028,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9780
10028
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
9781
10029
  // UIPickerView exposes adjustable component wheels. Making the
9782
10030
  // picker itself a leaf hides those wheels and their selected values.
9783
- let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
10031
+ let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
9784
10032
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
9785
10033
  || controlledVideo
9786
10034
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -9883,6 +10131,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9883
10131
  /// Return on a `returnKeyType="next"` field the screen does not handle
9884
10132
  /// itself (no `@submit`) goes to the next field down the screen, keeping
9885
10133
  /// the keyboard up. Every other Return ends editing and fires `@submit`.
10134
+ func textFieldShouldClear(_ textField: UITextField) -> Bool {
10135
+ guard textField.isEnabled else { return false }
10136
+ textField.text = ""
10137
+ let id = ObjectIdentifier(textField)
10138
+ inputIdentities[id].map { inputDrafts[$0] = "" }
10139
+ let eventCount = noteSentText("", for: textField)
10140
+ if let handler = handlers[id] {
10141
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
10142
+ }
10143
+ return false // The model receives exactly one edit, including lazy models.
10144
+ }
10145
+
9886
10146
  func textFieldShouldReturn(_ textField: UITextField) -> Bool {
9887
10147
  guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
9888
10148
  let next = inputAfter(textField) else { return true }
@@ -9929,8 +10189,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9929
10189
  (textView as? CraftNativeTextView)?.refreshPlaceholder()
9930
10190
  keepCaretVisible(in: textView)
9931
10191
  guard let handler = handlers[id] else { return }
9932
- noteSentText(textView.text ?? "", for: textView)
9933
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
10192
+ let eventCount = noteSentText(textView.text ?? "", for: textView)
10193
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
9934
10194
  }
9935
10195
 
9936
10196
  func textViewDidChangeSelection(_ textView: UITextView) {
@@ -9951,6 +10211,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9951
10211
  textView.invalidateIntrinsicContentSize()
9952
10212
  CraftNativeFlexLayout.invalidateAncestors(of: textView)
9953
10213
  self.view.layoutIfNeeded()
10214
+ // That layout can move the scroll view: its keyboard inset is
10215
+ // measured where it now ends before the caret is.
10216
+ if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
9954
10217
  guard let scroll = self.enclosingVerticalScroll(of: textView),
9955
10218
  let rect = self.caretRect(of: textView, in: scroll) else { return }
9956
10219
  let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
@@ -9983,14 +10246,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9983
10246
  inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
9984
10247
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
9985
10248
  guard let handler = handlers[id] else { return }
9986
- noteSentText(sender.text ?? "", for: sender)
9987
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
10249
+ let eventCount = noteSentText(sender.text ?? "", for: sender)
10250
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
9988
10251
  restoreFocus(focused)
9989
10252
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
9990
10253
  self?.restoreFocus(focused)
9991
10254
  }
9992
10255
  }
9993
10256
 
10257
+ @objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
10258
+ let index = sender.selectedSegmentIndex
10259
+ guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
10260
+ let handler = handlers[ObjectIdentifier(sender)] else { return }
10261
+ sender.refreshAccessibilityState()
10262
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
10263
+ }
10264
+
9994
10265
  @objc private func switchChanged(_ sender: UISwitch) {
9995
10266
  let id = ObjectIdentifier(sender)
9996
10267
  switchUserValues[id] = sender.isOn
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
117
117
  }
118
118
 
119
119
  private func apply(_ payload: [String: Any]) {
120
+ if let incomingVersion = payload["stateVersion"] as? Int {
121
+ guard incomingVersion >= stateVersion else { return }
122
+ stateVersion = incomingVersion
123
+ }
120
124
  if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
121
125
  plannedSession = plan
122
126
  status = "Plan ready"