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/android/src/index.test.tsmodified+22-0
Changes to packages/android/src/index.test.ts
@@ -34,6 +34,28 @@ describe('Craft Android builder', () => {
3434 .toThrow('postGenerate hook failed with exit code 7: patch failed')
3535 })
3636
37 it('generates static tab-bar selection appearance and validates its shape', async () => {
38 const output = createTempDir('craft-android-tab-bar-appearance-')
39 await init({ name: 'Tab Bar', output, runtimeDir: null, config: {
40 renderer: 'native',
41 tabBar: { selection: { cornerRadius: 8, color: '#123456' } },
42 } })
43 const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8'))
44 const shell = readFileSync(join(output, 'app/src/main/java/com/craft/tabbar/CraftNativeShell.kt'), 'utf8')
45 const activity = readFileSync(join(output, 'app/src/main/java/com/craft/tabbar/MainActivity.kt'), 'utf8')
46 expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } })
47 expect(shell).toContain('data class CraftNativeTabBarAppearance')
48 expect(shell).toContain('private fun tabCellBackground(index: Int, dark: Boolean)')
49 expect(activity).toContain('routes.tabBarAppearance')
50 await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: {
51 renderer: 'native', tabBar: { selection: { cornerRadius: -1 } },
52 } })).rejects.toThrow('cornerRadius must be a finite non-negative number')
53 await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: {
54 renderer: 'native', tabBar: { selection: { color: 'red' } },
55 } })).rejects.toThrow('color must be a #RRGGBB color')
56 })
57
58
3759 it('bundles custom fonts with their licenses and removes retired fonts', async () => {
3860 const root = createTempDir('craft-native-fonts-')
3961 const font = join(root, 'Signature.ttf')
packages/android/src/index.tsmodified+16-0
Changes to packages/android/src/index.ts
@@ -138,6 +138,8 @@ export interface CraftAndroidConfig {
138138 renderer?: 'web' | 'native'
139139 /** "system" follows the device; light/dark pin the generated host. */
140140 appearance?: 'light' | 'dark' | 'system'
141 /** Static native tab-bar styling applied before the page's own tabBar.set call. */
142 tabBar?: CraftTabBarConfig
141143 /** Runs from the generated project root before Gradle compiles the app. */
142144 postGenerate?: CraftPostGenerateHook
143145 version?: string
@@ -263,6 +265,15 @@ export interface CraftAndroidConfig {
263265 }
264266}
265267
268export interface CraftTabBarConfig {
269 selection?: {
270 /** The selected tab indicator's corner radius in dp; omitted keeps the default. */
271 cornerRadius?: number
272 /** The selected tab indicator fill as #RRGGBB. */
273 color?: string
274 }
275}
276
266277/** A declared command Craft runs after the native project has been generated. */
267278export interface CraftPostGenerateHook {
268279 command: string
@@ -565,6 +576,11 @@ function validateAndroidConfig(config: CraftAndroidConfig): void {
565576 if (config.appearance !== undefined && !['light', 'dark', 'system'].includes(config.appearance)) {
566577 throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`)
567578 }
579 const selection = config.tabBar?.selection
580 if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
581 throw new Error('Android tabBar.selection.cornerRadius must be a finite non-negative number')
582 if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
583 throw new Error('Android tabBar.selection.color must be a #RRGGBB color')
568584
569585 const packageSegments = config.packageName.split('.')
570586 if (packageSegments.length < 2
packages/android/templates/CraftNativeShell.kt.templatemodified+32-1
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -86,6 +86,11 @@ internal data class CraftNativeTab(
8686 val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null,
8787)
8888
89internal data class CraftNativeTabBarAppearance(
90 val selectionColor: String? = null,
91 val selectionCornerRadius: Float? = null,
92)
93
8994internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
9095
9196/** The app's paths (`nativeScreens`), its tabs and its site. */
@@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) {
122127 }
123128 }
124129
130 val tabBarAppearance: CraftNativeTabBarAppearance = config.optJSONObject("tabBar")
131 ?.optJSONObject("selection")
132 ?.let { selection ->
133 CraftNativeTabBarAppearance(
134 selection.optString("color").takeIf { it.isNotBlank() },
135 (selection.opt("cornerRadius") as? Number)?.toFloat()?.takeIf { it.isFinite() && it >= 0 },
136 )
137 }
138 ?: CraftNativeTabBarAppearance()
139
125140 /** The app's site (`devServerURL`): its origin answers paths no screen draws. */
126141 val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
127142 val siteOrigin: String? = site?.let { uri ->
@@ -755,7 +770,11 @@ internal object CraftNativePrelude {
755770 * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
756771 * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
757772 */
758internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
773internal class CraftNativeChrome(
774 context: Context,
775 private val tabs: List<CraftNativeTab>,
776 private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
777) : LinearLayout(context) {
759778 val content = FrameLayout(context)
760779 private val bar = LinearLayout(context)
761780 private val backButton = ImageView(context)
@@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
10991118 accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
11001119 tabViews.forEachIndexed { index, (icon, label) ->
11011120 tabBar.getChildAt(index).isSelected = index == selectedTab
1121 tabBar.getChildAt(index).background = tabCellBackground(index, dark)
11021122 val color = if (index == selectedTab) accent else muted
11031123 val id = tabs[index].id
11041124 val number = tabIconText[id]
@@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
11881208
11891209 private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
11901210
1211 private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1212 if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1213 val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
1214 ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
1215 val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
1216 return GradientDrawable().apply {
1217 setColor(color)
1218 cornerRadius = radius
1219 }
1220 }
1221
11911222 private fun rippleBackground(): android.graphics.drawable.Drawable? {
11921223 val value = TypedValue()
11931224 return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) {
packages/android/templates/MainActivityNative.kt.templatemodified+1-1
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -4198,7 +4198,7 @@ class MainActivity : AppCompatActivity() {
41984198 }
41994199
42004200 private fun installChrome() {
4201 val bars = CraftNativeChrome(this, routes.tabs)
4201 val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance)
42024202 bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
42034203 bars.onBack = { onBackPressedDispatcherCompat() }
42044204 bars.onAccessory = { path -> navigateOpen(path) }