ReviewOS

also looking at this

craft-native/craft

feat: finish native customization and platform seams

#401
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
21 files +707 -17

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/src/index.test.tsmodified+21-0
Changes to packages/ios/src/index.test.ts
@@ -58,6 +58,27 @@ describe('Craft iOS builder', () => {
5858 .toThrow('postGenerate hook failed with exit code 7: patch failed')
5959 })
6060
61 it('generates static tab-bar selection appearance and validates its shape', async () => {
62 const output = createTempDir('craft-ios-tab-bar-appearance-')
63 await init({ name: 'Tab Bar', output, runtimeDir: null, config: {
64 tabBar: { selection: { cornerRadius: 8, color: '#123456' } },
65 } })
66 const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8'))
67 const swift = readFileSync(join(output, 'Sources', 'TabBarApp.swift'), 'utf8')
68 const hybrid = readFileSync(join(output, 'Sources', 'CraftHybrid.swift'), 'utf8')
69 expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } })
70 expect(swift).toContain('private(set) var tabSelectionColor: UIColor?')
71 expect(swift).toContain('appearance: CraftConfig.TabBar? = nil')
72 expect(swift).toContain('chrome.tabSelectionColor.map { Color($0) }')
73 expect(hybrid).toContain('appearance: config.tabBar')
74 await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: {
75 tabBar: { selection: { cornerRadius: -1 } },
76 } })).rejects.toThrow('cornerRadius must be a finite non-negative number')
77 await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: {
78 tabBar: { selection: { color: 'red' } },
79 } })).rejects.toThrow('color must be a #RRGGBB color')
80 })
81
6182 it('restores the shell with the actual Xcode Cloud build identity without changing other resources', () => {
6283 const output = createTempDir('craft-cloud-identity-')
6384 const file = join(output, 'craft.config.json')
packages/ios/src/index.tsmodified+16-0
Changes to packages/ios/src/index.ts
@@ -74,6 +74,15 @@ export interface CraftPostGenerateHook {
7474 args?: string[]
7575}
7676
77export interface CraftTabBarConfig {
78 selection?: {
79 /** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
80 cornerRadius?: number
81 /** The selected tab indicator fill as #RRGGBB. */
82 color?: string
83 }
84}
85
7786export interface CraftConfig {
7887 appShortcuts?: CraftAppShortcut[]
7988 /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -92,6 +101,8 @@ export interface CraftConfig {
92101 * and `dark` pin one. Unset keeps the older `darkMode` switch.
93102 */
94103 appearance?: CraftAppearance
104 /** Static native tab-bar styling applied before the page's own tabBar.set call. */
105 tabBar?: CraftTabBarConfig
95106 /** Runs from the generated project root after `craft ios build` writes it. */
96107 postGenerate?: CraftPostGenerateHook
97108 backgroundColor?: string
@@ -1546,6 +1557,11 @@ export async function init(options: InitOptions): Promise<void> {
15461557 if (config.renderer !== 'web' && config.renderer !== 'native') {
15471558 throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`)
15481559 }
1560 const selection = config.tabBar?.selection
1561 if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1562 throw new Error('iOS tabBar.selection.cornerRadius must be a finite non-negative number')
1563 if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
1564 throw new Error('iOS tabBar.selection.color must be a #RRGGBB color')
15491565 if (config.enableBackgroundLocation) config.enableGeolocation = true
15501566 const problems = hybridConfigProblems(config)
15511567 if (problems.length) throw new Error(problems.join('\n'))
packages/ios/templates/CraftApp.swiftmodified+23-2
Changes to packages/ios/templates/CraftApp.swift
@@ -689,6 +689,10 @@ final class CraftChrome: ObservableObject {
689689 /// The selected tab highlight's corner radius. Nil preserves the default
690690 /// capsule, while a finite non-negative value opts into a rounded rect.
691691 private(set) var tabSelectionCornerRadius: CGFloat?
692 /// The configured static shape, retained when the page updates only its
693 /// tabs or selected item at runtime.
694 private var configuredTabSelectionCornerRadius: CGFloat?
695 private(set) var tabSelectionColor: UIColor?
692696
693697 weak var webView: WKWebView?
694698 /// What the tab bar covers at the bottom of the screen, in points.
@@ -723,9 +727,13 @@ final class CraftChrome: ObservableObject {
723727 /// The tab bar from `tabs` in craft.config.json, there before the page
724728 /// loads; the page's own `tabBar` message replaces it later. `splash`
725729 /// false when the first screen is native and draws itself.
726 func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
730 func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
727731 splashVisible = splash
728732 tabSwipeEnabled = true
733 configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
734 .flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
735 tabSelectionCornerRadius = configuredTabSelectionCornerRadius
736 tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
729737 guard !configured.isEmpty else { return }
730738 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) }
731739 self.selected = selected ?? configured.first?.id
@@ -856,6 +864,7 @@ final class CraftChrome: ObservableObject {
856864 tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
857865 }
858866 if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
867 if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
859868 withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
860869 publishLayout()
861870 case "selectTab":
@@ -1323,7 +1332,7 @@ private struct CraftTabSelectionIndicator: View {
13231332 let width = geometry.size.width / CGFloat(max(1, count))
13241333 CraftTabSelectionShape(cornerRadius: cornerRadius)
13251334 .frame(width: width, height: geometry.size.height)
1326 .foregroundStyle(Color.primary.opacity(0.09))
1335 .foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
13271336 .offset(x: position * width)
13281337 }
13291338 }
@@ -1706,6 +1715,9 @@ struct CraftConfig: Codable {
17061715 var nativeScreens: [String: String]? = nil
17071716 /// The tab bar to show before the page describes its own.
17081717 var tabs: [Tab]? = nil
1718 /// Static styling for the selected tab indicator; the page may still
1719 /// override its shape through `tabBar.set({ selection })`.
1720 var tabBar: TabBar? = nil
17091721 /// Hybrid: web-storage keys the page's own code writes, mirrored into the
17101722 /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
17111723 var shareStorage: [String: String]? = nil
@@ -1733,6 +1745,15 @@ struct CraftConfig: Codable {
17331745 var selectedIconPulse: Bool? = nil
17341746 }
17351747
1748 struct TabBar: Codable {
1749 var selection: TabSelection? = nil
1750 }
1751
1752 struct TabSelection: Codable {
1753 var cornerRadius: Double? = nil
1754 var color: String? = nil
1755 }
1756
17361757 struct BackgroundRefresh: Codable {
17371758 var enabled: Bool = false
17381759 /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
packages/ios/templates/CraftHybrid.swiftmodified+1-1
Changes to packages/ios/templates/CraftHybrid.swift
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
380380 }
381381 startsNative = isEnabled && routes.match(startPath) != nil
382382 // 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)
384384
385385 let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
386386 appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil