ReviewOS

also looking at this

craft-native/craft

fix(native): interaction, audio, document and checkpoint gaps stranded after #401

#415
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
19 files +987 -50

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/README.mdmodified+1-1
Changes to packages/ios/README.md
@@ -157,7 +157,7 @@ and linear gradients are applied by UIKit with the same style keys as Android;
157157`elevation` remains Android-specific. Gradient layers follow the native view's
158158bounds and corners through layout changes.
159159
160The 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).
160The 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. Dynamic `craft.shortcuts.set`/`clear` calls on an all-native screen resolve `false` (the typed `unsupported` result); configure static `appShortcuts` in `craft.config.json` when the app shell should publish home-screen quick actions. 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).
161161
162162`FlatList` rows are authored as keyed `:for` child rows in native STX; the
163163maintained protocol does not serialize `data`/`renderItem` callbacks.
packages/ios/fixtures/native-navigation/Home.stxmodified+15-0
Changes to packages/ios/fixtures/native-navigation/Home.stx
@@ -12,6 +12,8 @@ const notesEndEditings = state(0)
1212const status = state('')
1313const pressIns = state(0)
1414const pressOuts = state(0)
15const nestedButtonTaps = state(0)
16const nestedParentTaps = state(0)
1517const linkAccessibilityDisabled = state(false)
1618const panelAccessibilityDisabled = state(false)
1719const longPressStatus = state('')
@@ -399,6 +401,14 @@ function pressNativePressableOut() {
399401 pressOuts.update(value => value + 1)
400402}
401403
404function pressNestedButton() {
405 nestedButtonTaps.update(value => value + 1)
406}
407
408function pressNestedParent() {
409 nestedParentTaps.update(value => value + 1)
410}
411
402412function pressPanelLong() {
403413 longPressStatus.set('panel long pressed')
404414}
@@ -846,6 +856,11 @@ function openDetailsThenUpdateBackground() {
846856 <Pressable testID="native-linked-pressable" href="/linked-details" style={{"height":24}}><Text>Linked pressable</Text></Pressable>
847857 <Pressable testID="native-raw-pressable" style={{"height":24,"color":"#ef4444","fontSize":18,"textTransform":"uppercase"}}>Raw pressable</Pressable>
848858 <Text testID="pressable-lifecycle" :text="'Press lifecycle: ' + pressIns + '/' + pressOuts" />
859 <Pressable testID="nested-press-parent" @click="pressNestedParent()" style={{"height":44}}>
860 <Button testID="nested-press-button" @click="pressNestedButton()">Nested press</Button>
861 </Pressable>
862 <Text testID="nested-button-taps" :text="'Nested button taps: ' + nestedButtonTaps" />
863 <Text testID="nested-parent-taps" :text="'Nested parent taps: ' + nestedParentTaps" />
849864 <Pressable testID="disabled-pressable" disabled @click="pressNativePressable()" style={{"height":24}}><Text>Disabled pressable</Text></Pressable>
850865 <Button testID="toggle-panel-accessibility" @click="togglePanelAccessibility()">Toggle panel accessibility</Button>
851866 <View testID="long-press-panel" accessibilityState={longPressAccessibilityState()} @longpress="pressPanelLong()" style={{"height":24}}><Text>Long press panel</Text></View>
packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+20-0
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -743,6 +743,26 @@ final class NativeNavigationUITests: XCTestCase {
743743 XCTAssertTrue(scroll.exists, "keyed reconciliation replaced the list scroll host")
744744 }
745745
746 func testNestedButtonKeepsItsActionInsidePressable() throws {
747 let app = XCUIApplication()
748 app.launch()
749 let rootScroll = app.scrollViews["native-root-scroll"]
750 XCTAssertTrue(rootScroll.waitForExistence(timeout: 15))
751 let nestedButton = app.buttons["nested-press-button"]
752 XCTAssertTrue(nestedButton.waitForExistence(timeout: 15))
753 reveal(nestedButton, in: rootScroll)
754
755 let nestedButtonTaps = app.staticTexts["nested-button-taps"]
756 XCTAssertTrue(nestedButtonTaps.waitForExistence(timeout: 5))
757 XCTAssertEqual(nestedButtonTaps.label, "Nested button taps: 0")
758
759 nestedButton.tap()
760 XCTAssertTrue(waitForLabel(nestedButtonTaps, "Nested button taps: 1"), "nested Button action did not arrive")
761
762 nestedButton.tap()
763 XCTAssertTrue(waitForLabel(nestedButtonTaps, "Nested button taps: 2"), "nested Button action was lost or duplicated")
764 }
765
746766 func testPushNativeBackSwipeReplaceAndRetainedHomeState() throws {
747767 let app = XCUIApplication()
748768 app.launch()
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+50-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -2941,6 +2941,28 @@ final class NativeRenderUnitTests: XCTestCase {
29412941 XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled))
29422942 }
29432943
2944 func testNestedPressRecognizersGiveTheInnermostActionFirstRefusal() throws {
2945 let controller = CraftNativeScreenController(config: CraftConfig())
2946 controller.loadViewIfNeeded()
2947 controller.render(document([[
2948 "type": "Pressable", "props": ["key": "outer"], "events": ["onPress": "outerPressed"],
2949 "children": [[
2950 "type": "Pressable", "props": ["key": "inner"], "events": ["onPress": "innerPressed"],
2951 "children": [["type": "Text", "props": ["key": "label"], "children": ["Tap"]]],
2952 ]],
2953 ]]))
2954
2955 let outer = try XCTUnwrap(find(UIView.self, key: "outer", below: controller.view))
2956 let inner = try XCTUnwrap(find(UIView.self, key: "inner", below: controller.view))
2957 let outerTap = try XCTUnwrap(outer.gestureRecognizers?.first { $0 is UITapGestureRecognizer })
2958 let innerTap = try XCTUnwrap(inner.gestureRecognizers?.first { $0 is UITapGestureRecognizer })
2959 let delegate = try XCTUnwrap(outerTap.delegate as? CraftPressGestureDelegate)
2960 XCTAssertTrue(innerTap.delegate === CraftPressGestureDelegate.shared)
2961 XCTAssertTrue(delegate.gestureRecognizer(outerTap, shouldRequireFailureOf: innerTap))
2962 XCTAssertFalse(delegate.gestureRecognizer(innerTap, shouldRequireFailureOf: outerTap))
2963 XCTAssertFalse(outerTap.cancelsTouchesInView)
2964 }
2965
29442966 func testModalCloseRequestAndDismissAreIndependentVisibilityEvents() throws {
29452967 let controller = CraftNativeScreenController(config: CraftConfig())
29462968 controller.loadViewIfNeeded()
@@ -3492,6 +3514,34 @@ final class NativeRenderUnitTests: XCTestCase {
34923514 XCTAssertEqual(authored.accessibilityContainerType, .none)
34933515 }
34943516
3517 func testLabelledModalAndDrawerExposeTheirInteractiveChildren() throws {
3518 let controller = CraftNativeScreenController(config: CraftConfig())
3519 controller.loadViewIfNeeded()
3520 controller.render(document([
3521 [
3522 "type": "Modal",
3523 "props": ["key": "labelled-modal", "visible": true, "accessibilityLabel": "Outer dialog"],
3524 "children": [["type": "Button", "props": ["key": "modal-action"], "children": ["Open nested"]]],
3525 ],
3526 [
3527 "type": "Drawer",
3528 "props": ["key": "labelled-drawer", "accessibilityLabel": "Filters"],
3529 "children": [["type": "Button", "props": ["key": "drawer-action"], "children": ["Apply"]]],
3530 ],
3531 ]))
3532
3533 let modal = try XCTUnwrap(find(UIView.self, key: "labelled-modal", below: controller.view))
3534 let drawer = try XCTUnwrap(find(UIView.self, key: "labelled-drawer", below: controller.view))
3535 XCTAssertFalse(modal.isAccessibilityElement)
3536 XCTAssertFalse(drawer.isAccessibilityElement)
3537 XCTAssertEqual(modal.accessibilityLabel, "Outer dialog")
3538 XCTAssertEqual(drawer.accessibilityLabel, "Filters")
3539 XCTAssertEqual(modal.accessibilityContainerType, .semanticGroup)
3540 XCTAssertEqual(drawer.accessibilityContainerType, .semanticGroup)
3541 XCTAssertTrue(try XCTUnwrap(find(UIButton.self, key: "modal-action", below: modal)).isAccessibilityElement)
3542 XCTAssertTrue(try XCTUnwrap(find(UIButton.self, key: "drawer-action", below: drawer)).isAccessibilityElement)
3543 }
3544
34953545 func testSwitchAccessibilityMatchesUIKitAcrossValueAndEnabledChanges() throws {
34963546 let controller = CraftNativeScreenController(config: CraftConfig())
34973547 controller.loadViewIfNeeded()
packages/ios/src/index.test.tsmodified+12-2
Changes to packages/ios/src/index.test.ts
@@ -1611,6 +1611,16 @@ describe('Craft iOS builder', () => {
16111611 expect(screen).toContain('is not available on a native screen')
16121612 })
16131613
1614 it('exposes the native shortcut module as an explicit unsupported result', async () => {
1615 const output = createTempDir('craft-ios-native-shortcuts-')
1616 await init({ runtimeDir: null, name: 'NativeShortcuts', bundleId: 'org.example.nativeshortcuts', output, config: { renderer: 'native' } as any })
1617 const screen = readFileSync(join(output, 'Sources', 'CraftNativeScreen.swift'), 'utf8')
1618 expect(screen).toContain("keep('shortcuts', {")
1619 expect(screen).toContain('set: function() { return Promise.resolve(false); }')
1620 expect(screen).toContain('clear: function() { return Promise.resolve(false); }')
1621 expect(screen).toContain('onShortcut: function() { return function() {}; }')
1622 })
1623
16141624 it('keeps generated iOS media limits strict and finite', async () => {
16151625 const output = createTempDir('craft-ios-native-media-numbers-')
16161626 await init({ runtimeDir: null, name: 'NativeMediaNumbers', bundleId: 'org.example.nativemedianumbers', output })
@@ -1751,11 +1761,11 @@ describe('Craft iOS builder', () => {
17511761 expect(swift).toContain('field.borderStyle = style["borderWidth"] != nil || style["backgroundColor"] != nil ? .none : .roundedRect')
17521762 })
17531763
1754 it('keeps a labelled scroll view, picker or group a container, so VoiceOver reads what it holds', () => {
1764 it('keeps labelled scroll views, pickers, modals, drawers and groups as containers', () => {
17551765 // A tour pager labelled for VoiceOver became one element and hid its pages.
17561766 const swift = readFileSync(join(import.meta.dir, '..', 'templates', 'CraftNativeScreen.swift'), 'utf8')
17571767 const a11y = swift.slice(swift.indexOf('private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView)'))
1758 expect(a11y).toContain('let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)')
1768 expect(a11y).toContain('let container = ["ScrollView", "FlatList", "SectionList", "Picker", "Modal", "Drawer"].contains(type)')
17591769 expect(a11y).toContain('view.isAccessibilityElement = !container && role != "none"')
17601770 expect(a11y).toContain('view.accessibilityContainerType = container && (view.accessibilityLabel != nil || controlledVideo) ? .semanticGroup : .none')
17611771 expect(a11y).toContain('case "tablist": traits.insert(.tabBar)')
packages/ios/templates/CraftNativeScreen.swiftmodified+58-1
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -4155,6 +4155,40 @@ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
41554155 }
41564156}
41574157
4158/// A pressable wrapper must yield to an actionable descendant. Without this
4159/// ordering UIKit may let the ancestor's tap recognizer win the same touch,
4160/// so a nested Button never receives its physical action. A descendant
4161/// control or tap recognizer is the source of the event; STX handles the
4162/// logical bubbling from there.
4163final class CraftPressGestureDelegate: NSObject, UIGestureRecognizerDelegate {
4164 static let shared = CraftPressGestureDelegate()
4165
4166 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
4167 guard let parent = gestureRecognizer.view,
4168 let child = otherGestureRecognizer.view,
4169 child !== parent else { return false }
4170 return child.isDescendant(of: parent)
4171 }
4172
4173 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
4174 guard let container = gestureRecognizer.view else { return true }
4175 // UIKit can report an internal UIButton label as `touch.view` while
4176 // the recognizer is attached to an ancestor. Resolve the hit-test
4177 // path from the container as well so that the ancestor never claims
4178 // a control's physical tap (the child action will bubble in STX).
4179 let hitView = container.hitTest(touch.location(in: container), with: nil)
4180 guard var candidate = hitView ?? touch.view else { return true }
4181 while candidate !== container {
4182 if candidate is UIControl { return false }
4183 if candidate.gestureRecognizers?.contains(where: { $0 !== gestureRecognizer && $0 is UITapGestureRecognizer }) == true {
4184 return false
4185 }
4186 candidate = candidate.superview ?? container
4187 }
4188 return true
4189 }
4190}
4191
41584192/// The delegate of a Pressable's press-in/out feedback. That recognizer is
41594193/// a long press of no duration, so it begins the moment a finger lands; on its
41604194/// own it would claim the touch, and the tap that is the Pressable's onPress
@@ -6562,6 +6596,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
65626596 keep('navigation', {
65636597 setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
65646598 });
6599 // Dynamic home-screen quick actions belong to the app shell,
6600 // not an individual JavaScriptCore screen. Keep the typed
6601 // module present so STX can answer `unsupported` honestly
6602 // instead of falling through to an undefined namespace.
6603 keep('shortcuts', {
6604 set: function() { return Promise.resolve(false); },
6605 clear: function() { return Promise.resolve(false); },
6606 onShortcut: function() { return function() {}; }
6607 });
65656608 })();
65666609 """)
65676610 }
@@ -8965,6 +9008,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
89659008 let actionable = handler != nil || linkTargets[id] != nil
89669009 if actionable, tapRecognizers[id] == nil {
89679010 let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
9011 recognizer.cancelsTouchesInView = false
9012 recognizer.delegate = CraftPressGestureDelegate.shared
89689013 tapRecognizers[id] = recognizer
89699014 view.addGestureRecognizer(recognizer)
89709015 } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) {
@@ -9836,7 +9881,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
98369881 let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
98379882 // UIPickerView exposes adjustable component wheels. Making the
98389883 // picker itself a leaf hides those wheels and their selected values.
9839 let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
9884 let container = ["ScrollView", "FlatList", "SectionList", "Picker", "Modal", "Drawer"].contains(type)
98409885 || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
98419886 || controlledVideo
98429887 view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -10240,6 +10285,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1024010285
1024110286 @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
1024210287 guard let view = sender.view else { return }
10288 // UIKit may still deliver an ancestor recognizer after the delegate
10289 // has negotiated with a control's touch recognizer. The child control
10290 // owns that physical tap; STX will perform logical bubbling from its
10291 // event, so do not send the ancestor handler a second time here.
10292 if let hit = view.hitTest(sender.location(in: view), with: nil), hit !== view {
10293 var candidate: UIView? = hit
10294 while let current = candidate, current !== view {
10295 if current is UIControl { return }
10296 if current.gestureRecognizers?.contains(where: { $0 !== sender && $0 is UITapGestureRecognizer }) == true { return }
10297 candidate = current.superview
10298 }
10299 }
1024310300 let id = ObjectIdentifier(view)
1024410301 if let target = linkTargets[id] {
1024510302 hybridEvents?.nativeScreen(self, open: target, props: [:])