diff --git a/README.md b/README.md index 9990de2..8e1ec26 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,36 @@ public function handleNativeUICompleted($result, $id = null) } ``` +## Theming & Colors + +Theme tokens live in `config/native-ui.php` (publish with +`php artisan vendor:publish --tag=native-ui-config`). Every authored color — +theme tokens, element color props, and arbitrary-value classes — accepts the +same grammar: + +```php +'light' => [ + 'primary' => 'violet-600', // Tailwind palette name + 'secondary' => 'fuchsia-500/70', // opacity modifier → tonal fill + 'surface' => '#F8FAFC', // plain hex (#RGB / #RRGGBB) + 'accent' => '#00AAA680', // CSS alpha hex (#RRGGBBAA) +], +``` + +Alpha hex is authored in CSS `#RRGGBBAA` order; the framework converts to the +native wire format. Dark mode is auto-derived from `light` (alpha preserved) +unless a `dark` block overrides specific tokens. + +Disabled controls draw from the `surface-variant` (fill) and +`on-surface-variant` (label) tokens on both platforms — adjust those two +tokens to tune disabled contrast app-wide. + +Icons accept platform enum overrides in Blade, matching the fluent API: + +```blade + +``` + ## Accessibility Every element accepts a screen-reader label and an optional hint, via Blade diff --git a/config/native-ui.php b/config/native-ui.php index 975fbc1..424f04e 100644 --- a/config/native-ui.php +++ b/config/native-ui.php @@ -24,6 +24,11 @@ | "on-X" means "color of content placed ON a surface of color X" | — i.e., text/icons on that background. | + | Color tokens accept: + | - CSS hex: '#B91C1C', '#F00', or with alpha '#8B5CF680' (#RRGGBBAA) + | - Tailwind palette names: 'red-300', 'orange-800' + | - Opacity modifiers on either: 'red-300/20', '#8B5CF6/50' + | | Dark mode is auto-derived from `light` when `dark` is not set. To opt | into explicit dark tokens, fill out the `dark` block. | @@ -105,7 +110,10 @@ 'font-xl' => 24, // 'System' resolves to the platform default (San Francisco on iOS, Roboto on Android). - // Use a specific family name to load a custom font. + // Set a bundled font token to apply it app-wide — a file from your app's + // resources/fonts/ minus the extension (e.g. 'Inter-Regular'). Download one + // with `php artisan native:font Inter`. Per-element `font` attributes and + // font-serif / font-mono classes still win over this default. 'font-family' => 'System', ], diff --git a/resources/android/BadgeRenderer.kt b/resources/android/BadgeRenderer.kt index b4da12d..abee66b 100644 --- a/resources/android/BadgeRenderer.kt +++ b/resources/android/BadgeRenderer.kt @@ -54,6 +54,7 @@ object BadgeRenderer { Box(modifier = boxModifier, contentAlignment = Alignment.Center) { Text( text = text, + fontFamily = nuiDefaultFontFamily(), color = fg, fontSize = 12.sp, fontWeight = FontWeight.Bold, diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index fef8cc4..fbebd4b 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -43,7 +43,8 @@ object BareTextInputRenderer { val props = parseTextInputProps(node) val isDark = isSystemInDarkTheme() val theme = if (isDark) NativeUITheme.dark else NativeUITheme.light - val customFontFamily = if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null + val customFontFamily = (if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null) + ?: nuiThemeDefaultFontFamily(LocalContext.current) val lineHeight = nuiLineHeightUnit(props.lineHeightPx, props.lineHeight, props.textSize.toFloat()) // Per-instance color override. `color` is the light value; diff --git a/resources/android/ButtonGroupRenderer.kt b/resources/android/ButtonGroupRenderer.kt index 694a643..c6651f6 100644 --- a/resources/android/ButtonGroupRenderer.kt +++ b/resources/android/ButtonGroupRenderer.kt @@ -76,7 +76,7 @@ object ButtonGroupRenderer { selected = index == selectedIndex, enabled = !disabled, colors = colors, - label = { Text(label) }, + label = { Text(label, fontFamily = nuiDefaultFontFamily()) }, ) } } diff --git a/resources/android/ButtonRenderer.kt b/resources/android/ButtonRenderer.kt index f3c82b1..a3a3caa 100644 --- a/resources/android/ButtonRenderer.kt +++ b/resources/android/ButtonRenderer.kt @@ -63,7 +63,8 @@ object ButtonRenderer { val pressCb = p.getCallbackId("on_press").let { if (it != 0) it else node.onPress } val hasMenu = p.getBool("has_menu") val fontName = p.getString("font_name") - val customFontFamily = if (fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, fontName) else null + val customFontFamily = (if (fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, fontName) else null) + ?: nuiThemeDefaultFontFamily(LocalContext.current) // Read the active token set from the shared store. Using the singleton // rather than a CompositionLocal because nothing in the render tree @@ -112,19 +113,23 @@ object ButtonRenderer { // inside a Box (with DropdownMenu) when `:menu` is attached. val buttonByVariant: @Composable () -> Unit = { when (variant) { + // Disabled state (all variants): `surface-variant` fill + + // `on-surface-variant` label from the theme, replacing M3's + // default onSurface-at-38% which read too faint. iOS uses the + // same token pair, so disabled looks identical cross-platform. "secondary" -> FilledTonalButton( onClick = onClick, enabled = enabled, modifier = buttonModifier, contentPadding = metrics.contentPadding, - // Colors come from the theme config (native-ui.php), matching - // iOS which uses `theme.secondary` + `theme.onSecondary`. The - // tonal alpha softens the fill a touch vs a fully-saturated - // solid (what read as "too saturated") while staying dark - // enough for the `onSecondary` label to read. + // Solid fill of the secondary token, matching iOS. No + // renderer-imposed alpha: transparency belongs to the + // theme config (e.g. `'secondary' => 'fuchsia-500/70'`). colors = ButtonDefaults.filledTonalButtonColors( - containerColor = theme.secondary.copy(alpha = 0.7f), + containerColor = theme.secondary, contentColor = theme.onSecondary, + disabledContainerColor = theme.surfaceVariant, + disabledContentColor = theme.onSurfaceVariant, ), content = { content() }, ) @@ -137,6 +142,8 @@ object ButtonRenderer { colors = ButtonDefaults.buttonColors( containerColor = theme.destructive, contentColor = theme.onDestructive, + disabledContainerColor = theme.surfaceVariant, + disabledContentColor = theme.onSurfaceVariant, ), content = { content() }, ) @@ -146,7 +153,10 @@ object ButtonRenderer { enabled = enabled, modifier = buttonModifier, contentPadding = metrics.contentPadding, - colors = ButtonDefaults.textButtonColors(contentColor = theme.primary), + colors = ButtonDefaults.textButtonColors( + contentColor = theme.primary, + disabledContentColor = theme.onSurfaceVariant, + ), content = { content() }, ) @@ -159,6 +169,8 @@ object ButtonRenderer { colors = ButtonDefaults.buttonColors( containerColor = theme.primary, contentColor = theme.onPrimary, + disabledContainerColor = theme.surfaceVariant, + disabledContentColor = theme.onSurfaceVariant, ), content = { content() }, ) diff --git a/resources/android/CheckboxRenderer.kt b/resources/android/CheckboxRenderer.kt index 097d911..45f0044 100644 --- a/resources/android/CheckboxRenderer.kt +++ b/resources/android/CheckboxRenderer.kt @@ -88,7 +88,7 @@ object CheckboxRenderer { colors = colors, ) if (label.isNotEmpty()) { - Text(text = label, color = theme.onSurface) + Text(text = label, fontFamily = nuiDefaultFontFamily(), color = theme.onSurface) } } } diff --git a/resources/android/ChipRenderer.kt b/resources/android/ChipRenderer.kt index e277e0f..679173e 100644 --- a/resources/android/ChipRenderer.kt +++ b/resources/android/ChipRenderer.kt @@ -75,7 +75,7 @@ object ChipRenderer { NativeUIBridge.sendToggleChangeEvent(onChangeCb, node.id, new) } }, - label = { Text(label) }, + label = { Text(label, fontFamily = nuiDefaultFontFamily()) }, modifier = chipModifier, enabled = !disabled, leadingIcon = if (iconName.isNotEmpty()) { diff --git a/resources/android/ContainerRenderers.kt b/resources/android/ContainerRenderers.kt index d097f5a..b478e69 100644 --- a/resources/android/ContainerRenderers.kt +++ b/resources/android/ContainerRenderers.kt @@ -189,6 +189,7 @@ internal fun renderAttachedMenuItem(item: NativeUINode, onSelected: () -> Unit) } Text( itemLabel, + fontFamily = nuiDefaultFontFamily(), color = labelColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 1e528d4..1f880fc 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -83,7 +83,8 @@ object FilledTextInputRenderer { "lg" -> theme.fontLg else -> theme.fontMd } - val customFontFamily = if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null + val customFontFamily = (if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null) + ?: nuiThemeDefaultFontFamily(LocalContext.current) val lineHeight = nuiLineHeightUnit(props.lineHeightPx, props.lineHeight, textSize.value) TextField( diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index b2a5e12..9fa3734 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -115,6 +115,7 @@ object ListItemRenderer { headlineContent = { Text( text = headline, + fontFamily = nuiDefaultFontFamily(), color = if (headlineColor != 0) Color(headlineColor) else Color.Unspecified ) }, @@ -123,6 +124,7 @@ object ListItemRenderer { { Text( text = overline, + fontFamily = nuiDefaultFontFamily(), color = if (overlineColor != 0) Color(overlineColor) else Color.Unspecified ) } @@ -131,6 +133,7 @@ object ListItemRenderer { { Text( text = supporting, + fontFamily = nuiDefaultFontFamily(), color = if (supportingColor != 0) Color(supportingColor) else Color.Unspecified ) } @@ -260,6 +263,7 @@ object ListItemRenderer { ) { Text( text = effectiveValue.take(2).uppercase(), + fontFamily = nuiDefaultFontFamily(), color = textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, @@ -398,6 +402,7 @@ object ListItemRenderer { "text" -> { Text( text = effectiveValue, + fontFamily = nuiDefaultFontFamily(), color = if (textColor != 0) Color(textColor) else Color.Unspecified ) } diff --git a/resources/android/ListRenderer.kt b/resources/android/ListRenderer.kt index 90a464b..1187fde 100644 --- a/resources/android/ListRenderer.kt +++ b/resources/android/ListRenderer.kt @@ -211,6 +211,7 @@ private fun SectionHeader(text: String) { ) { Text( text = text.uppercase(), + fontFamily = nuiDefaultFontFamily(), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, @@ -263,6 +264,7 @@ private fun SectionFooter(text: String, grouped: Boolean) { ) { Text( text = text, + fontFamily = nuiDefaultFontFamily(), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp, ) @@ -445,6 +447,7 @@ private fun SwipeActionButton( if (action.label.isNotEmpty()) { Text( text = action.label, + fontFamily = nuiDefaultFontFamily(), color = Color.White, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, fontSize = 12.sp, diff --git a/resources/android/NativeUIChromeInit.kt b/resources/android/NativeUIChromeInit.kt index 0e10f56..c5c687f 100644 --- a/resources/android/NativeUIChromeInit.kt +++ b/resources/android/NativeUIChromeInit.kt @@ -1,10 +1,13 @@ package com.nativephp.plugins.native_ui import android.content.Context +import androidx.compose.ui.platform.LocalContext import com.nativephp.mobile.ui.NativeUIThemeProvider import com.nativephp.mobile.ui.nativerender.NativeRootHostRegistry import com.nativephp.plugins.native_ui.ui.NativeFloatingOverlayHost import com.nativephp.plugins.native_ui.ui.NativeLayoutDrawerHost +import com.nativephp.plugins.native_ui.ui.NativeUIFontResolver +import com.nativephp.plugins.native_ui.ui.nuiThemeDefaultTypography /** * Init function invoked by the generated `PluginBridgeFunctionRegistration` in @@ -14,9 +17,9 @@ import com.nativephp.plugins.native_ui.ui.NativeLayoutDrawerHost * manifest under `android.init_function`. * * The `context` parameter is supplied by the generated call site - * (`registerNativeUIChrome(context)`); these registrations need none of it. + * (`registerNativeUIChrome(context)`); the font resolver captures its + * application context for asset-backed font loading. */ -@Suppress("UNUSED_PARAMETER") fun registerNativeUIChrome(context: Context) { NativeRootHostRegistry.register("native-ui.drawer", consumes = "native_drawer") { root, content -> val drawerNode = root.children.firstOrNull { it.type == "native_drawer" } @@ -34,4 +37,21 @@ fun registerNativeUIChrome(context: Context) { NativeUIThemeProvider.colorSchemeFor = { isDark -> (if (isDark) NativeUITheme.dark else NativeUITheme.light).toMaterialColorScheme(isDark) } + + // Supply the app's typography when a default font is configured (the + // theme's `font-family` token). Every Material text style keeps its size / + // weight / spacing and swaps only the family, so core chrome — top bar + // titles, tab labels, dropdowns — renders in the app's font. Returns null + // (Material defaults) when the token is "System". + NativeUIThemeProvider.typographyFor = { + nuiThemeDefaultTypography(LocalContext.current) + } + + // Resolve chrome font tokens (per-layout / per-bar `font_name` props on + // the root sentinels) for core's chrome renderers. Captures the + // application context — asset-backed fonts don't need an activity. + val appContext = context.applicationContext + NativeUIThemeProvider.fontFamilyResolver = { name -> + NativeUIFontResolver.resolve(appContext, name) + } } diff --git a/resources/android/NativeUIFontResolver.kt b/resources/android/NativeUIFontResolver.kt index 172a496..3eb0bd8 100644 --- a/resources/android/NativeUIFontResolver.kt +++ b/resources/android/NativeUIFontResolver.kt @@ -2,10 +2,14 @@ package com.nativephp.plugins.native_ui.ui import android.content.Context import android.content.res.AssetManager +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp +import com.nativephp.plugins.native_ui.NativeUITheme // Tailwind `leading`: line_height_px is an absolute sp target; line_height is a // unitless multiplier of the font size. Unspecified leaves Compose's default. @@ -16,6 +20,67 @@ internal fun nuiLineHeightUnit(px: Float, mult: Float, fontSize: Float): TextUni else -> TextUnit.Unspecified } +/** + * App-wide default font from the theme's `font-family` token, or null when + * unset / "System". Read inside composition: the theme store is backed by + * `mutableStateOf`, so a PHP theme push recomposes consumers. The token is + * identical for light and dark, so reading the light set suffices. + * + * Precedence at call sites: explicit `font_name` prop, then an explicit + * `font-serif`/`font-mono` class, then this, then the system font. + */ +internal fun nuiThemeDefaultFontFamily(context: Context): FontFamily? { + val family = NativeUITheme.light.fontFamily + if (family.isEmpty() || family == "System") return null + + return NativeUIFontResolver.resolve(context, family) +} + +/** + * Composable sugar for [nuiThemeDefaultFontFamily] — grabs LocalContext so a + * `Text(...)` site can just say `fontFamily = nuiDefaultFontFamily()`. + */ +@Composable +internal fun nuiDefaultFontFamily(): FontFamily? = nuiThemeDefaultFontFamily(LocalContext.current) + +/** + * Per-node font: an explicit `font_name` prop wins, then the app-wide theme + * default, then null (system font). + */ +@Composable +internal fun nuiNodeFontFamily(name: String): FontFamily? = + (if (name.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, name) else null) + ?: nuiDefaultFontFamily() + +/** + * Material3 [Typography] carrying the app-wide default font on every text + * style (sizes / weights / spacing untouched), or null when no default font + * is configured. Fed to core chrome through NativeUIThemeProvider.typographyFor + * so top bars, tab labels, and dropdowns render in the app's font. + */ +fun nuiThemeDefaultTypography(context: Context): Typography? { + val family = nuiThemeDefaultFontFamily(context) ?: return null + + val base = Typography() + return Typography( + displayLarge = base.displayLarge.copy(fontFamily = family), + displayMedium = base.displayMedium.copy(fontFamily = family), + displaySmall = base.displaySmall.copy(fontFamily = family), + headlineLarge = base.headlineLarge.copy(fontFamily = family), + headlineMedium = base.headlineMedium.copy(fontFamily = family), + headlineSmall = base.headlineSmall.copy(fontFamily = family), + titleLarge = base.titleLarge.copy(fontFamily = family), + titleMedium = base.titleMedium.copy(fontFamily = family), + titleSmall = base.titleSmall.copy(fontFamily = family), + bodyLarge = base.bodyLarge.copy(fontFamily = family), + bodyMedium = base.bodyMedium.copy(fontFamily = family), + bodySmall = base.bodySmall.copy(fontFamily = family), + labelLarge = base.labelLarge.copy(fontFamily = family), + labelMedium = base.labelMedium.copy(fontFamily = family), + labelSmall = base.labelSmall.copy(fontFamily = family), + ) +} + /** * Resolves a custom-font token (a font file's basename, e.g. "Inter-Bold") to a * Compose [FontFamily] loaded from the app's `assets/fonts/`. Fonts land there diff --git a/resources/android/NavRenderers.kt b/resources/android/NavRenderers.kt index c9b3ee5..0228e45 100644 --- a/resources/android/NavRenderers.kt +++ b/resources/android/NavRenderers.kt @@ -103,9 +103,9 @@ object TopBarRenderer { } // Title + optional subtitle stacked Column(modifier = Modifier.weight(1f)) { - Text(text = title, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = textColor) + Text(text = title, fontFamily = nuiNodeFontFamily(node.props.getString("font_name")), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = textColor) if (subtitle.isNotEmpty()) { - Text(text = subtitle, fontSize = 12.sp, color = textColor.copy(alpha = 0.7f)) + Text(text = subtitle, fontFamily = nuiNodeFontFamily(node.props.getString("font_name")), fontSize = 12.sp, color = textColor.copy(alpha = 0.7f)) } } val actions = node.children.filter { it.type == "top_bar_action" } @@ -227,6 +227,7 @@ object BottomNavRenderer { ) { Text( text = badge, + fontFamily = nuiNodeFontFamily(node.props.getString("font_name")), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White, @@ -245,7 +246,7 @@ object BottomNavRenderer { } } if (showLabel && label.isNotEmpty()) { - Text(text = label, fontSize = 12.sp, color = itemColor, textAlign = TextAlign.Center) + Text(text = label, fontFamily = nuiNodeFontFamily(node.props.getString("font_name")), fontSize = 12.sp, color = itemColor, textAlign = TextAlign.Center) } } } diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 2ab77d4..aa5617d 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -92,7 +92,8 @@ object OutlinedTextInputRenderer { "lg" -> theme.fontLg else -> theme.fontMd } - val customFontFamily = if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null + val customFontFamily = (if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null) + ?: nuiThemeDefaultFontFamily(LocalContext.current) val lineHeight = nuiLineHeightUnit(props.lineHeightPx, props.lineHeight, textSize.value) OutlinedTextField( diff --git a/resources/android/RadioGroupRenderer.kt b/resources/android/RadioGroupRenderer.kt index 5c0aa09..d212879 100644 --- a/resources/android/RadioGroupRenderer.kt +++ b/resources/android/RadioGroupRenderer.kt @@ -51,7 +51,7 @@ object RadioGroupRenderer { Column(modifier = groupModifier) { if (label.isNotEmpty()) { - Text(text = label, color = theme.onSurfaceVariant) + Text(text = label, fontFamily = nuiDefaultFontFamily(), color = theme.onSurfaceVariant) Spacer(Modifier.height(8.dp)) } node.children.forEach { child -> diff --git a/resources/android/RadioRenderer.kt b/resources/android/RadioRenderer.kt index 0d0fa42..d3decda 100644 --- a/resources/android/RadioRenderer.kt +++ b/resources/android/RadioRenderer.kt @@ -75,7 +75,7 @@ object RadioRenderer { colors = colors, ) if (label.isNotEmpty()) { - Text(text = label, color = theme.onSurface) + Text(text = label, fontFamily = nuiDefaultFontFamily(), color = theme.onSurface) } } } diff --git a/resources/android/SelectRenderer.kt b/resources/android/SelectRenderer.kt index 5a600b4..f428552 100644 --- a/resources/android/SelectRenderer.kt +++ b/resources/android/SelectRenderer.kt @@ -66,8 +66,8 @@ object SelectRenderer { onValueChange = {}, readOnly = true, enabled = !disabled, - label = if (label.isNotEmpty()) ({ Text(label) }) else null, - placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder) }) else null, + label = if (label.isNotEmpty()) ({ Text(label, fontFamily = nuiDefaultFontFamily()) }) else null, + placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder, fontFamily = nuiDefaultFontFamily()) }) else null, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), textStyle = TextStyle(color = theme.onSurface), @@ -91,7 +91,7 @@ object SelectRenderer { ) { options.forEach { option -> DropdownMenuItem( - text = { Text(option, color = theme.onSurface) }, + text = { Text(option, fontFamily = nuiDefaultFontFamily(), color = theme.onSurface) }, onClick = { selectedValue = option lastSentValue = option diff --git a/resources/android/TabRowRenderer.kt b/resources/android/TabRowRenderer.kt index ad34f0a..3660f06 100644 --- a/resources/android/TabRowRenderer.kt +++ b/resources/android/TabRowRenderer.kt @@ -76,7 +76,7 @@ object TabRowRenderer { modifier = if (tabA11y.isNotEmpty()) { Modifier.semantics { contentDescription = tabA11y } } else Modifier, - text = if (tabLabel.isNotEmpty()) ({ Text(tabLabel) }) else null, + text = if (tabLabel.isNotEmpty()) ({ Text(tabLabel, fontFamily = nuiDefaultFontFamily()) }) else null, icon = if (tabIcon.isNotEmpty()) { { MaterialIcon(name = tabIcon, contentDescription = null, size = 24.dp) } } else null, diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 19db905..0cbfdee 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -210,23 +210,23 @@ internal class TextInputDispatcher( /** Common slot renderers — shared between OutlinedTextField and TextField. */ @Composable internal fun labelSlot(text: String): (@Composable () -> Unit)? = - if (text.isEmpty()) null else ({ Text(text) }) + if (text.isEmpty()) null else ({ Text(text, fontFamily = nuiDefaultFontFamily()) }) @Composable internal fun placeholderSlot(text: String): (@Composable () -> Unit)? = - if (text.isEmpty()) null else ({ Text(text) }) + if (text.isEmpty()) null else ({ Text(text, fontFamily = nuiDefaultFontFamily()) }) @Composable internal fun supportingSlot(text: String): (@Composable () -> Unit)? = - if (text.isEmpty()) null else ({ Text(text) }) + if (text.isEmpty()) null else ({ Text(text, fontFamily = nuiDefaultFontFamily()) }) @Composable internal fun prefixSlot(text: String): (@Composable () -> Unit)? = - if (text.isEmpty()) null else ({ Text(text) }) + if (text.isEmpty()) null else ({ Text(text, fontFamily = nuiDefaultFontFamily()) }) @Composable internal fun suffixSlot(text: String): (@Composable () -> Unit)? = - if (text.isEmpty()) null else ({ Text(text) }) + if (text.isEmpty()) null else ({ Text(text, fontFamily = nuiDefaultFontFamily()) }) @Composable internal fun leadingIconSlot(name: String): (@Composable () -> Unit)? = diff --git a/resources/android/TextRenderer.kt b/resources/android/TextRenderer.kt index 1921019..97d03c6 100644 --- a/resources/android/TextRenderer.kt +++ b/resources/android/TextRenderer.kt @@ -48,7 +48,9 @@ object TextRenderer { val fontStyle = resolveFontStyle(p.getInt("font_style")) val fontName = p.getString("font_name") val customFamily = if (fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, fontName) else null - val fontFamily = customFamily ?: resolveFontFamily(p.getInt("font_family")) + val fontFamily = customFamily + ?: resolveFontFamily(p.getInt("font_family")) + ?: nuiThemeDefaultFontFamily(LocalContext.current) val textDecoration = resolveDecoration(p.getInt("underline"), p.getInt("line_through")) val letterSpacingEm = p.getFloat("letter_spacing", 0f) val lineHeight = nuiLineHeightUnit(p.getFloat("line_height_px", 0f), p.getFloat("line_height", 0f), fontSize) @@ -183,7 +185,8 @@ private fun AnnotatedString.Builder.appendTextRuns(node: NativeUINode, inherited fontWeight = resolveFontWeight(ctx.fontWeightInt), fontStyle = if (ctx.italic) FontStyle.Italic else FontStyle.Normal, fontFamily = (if (ctx.fontName.isNotEmpty()) NativeUIFontResolver.resolve(context, ctx.fontName) else null) - ?: resolveFontFamily(ctx.fontFamilyInt), + ?: resolveFontFamily(ctx.fontFamilyInt) + ?: nuiThemeDefaultFontFamily(context), letterSpacing = if (ctx.letterSpacingEm != 0f) ctx.letterSpacingEm.em else TextUnit.Unspecified, textDecoration = resolveDecoration(p.getInt("underline"), p.getInt("line_through")), background = if (effectiveBg != 0) argbToComposeColor(effectiveBg) else Color.Unspecified, diff --git a/resources/android/ToggleRenderer.kt b/resources/android/ToggleRenderer.kt index c0b4b25..b51f013 100644 --- a/resources/android/ToggleRenderer.kt +++ b/resources/android/ToggleRenderer.kt @@ -86,7 +86,7 @@ object ToggleRenderer { ), verticalAlignment = Alignment.CenterVertically, ) { - Text(text = label, modifier = Modifier.weight(1f), color = theme.onSurface) + Text(text = label, fontFamily = nuiDefaultFontFamily(), modifier = Modifier.weight(1f), color = theme.onSurface) Spacer(modifier = Modifier.width(8.dp)) Switch( checked = checked, diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 5c4332c..d231a82 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -29,6 +29,42 @@ @endverbatim +### Theming & colors + +- Everywhere a color is authored — theme tokens in `config/native-ui.php`, + element color props (`->color()`, `headline-color`, badge `color`, swipe + `tint`), and arbitrary-value classes (`bg-[#…]`) — the same grammar applies: + - Tailwind palette names: `red-300`, `orange-800` + - Special names: `white`, `black`, `transparent` + - CSS hex: `#F00`, `#B91C1C`, and with alpha `#8B5CF680` (#RRGGBBAA order) + - Opacity modifiers on any of the above: `red-300/20`, `#8B5CF6/50` +- Alpha-bearing hex is always authored in CSS `#RRGGBBAA` order; PHP converts + to the native wire order — never hand-author Android-style `#AARRGGBB`. +- Dark mode: theme tokens carry a `dark` block (auto-derived when omitted), + and `bg-theme-*` / `text-theme-*` / `border-theme-*` classes emit both + modes automatically. This works for Blade-declared AND programmatically + built elements (`Element->class()`). +- Disabled controls use the `surface-variant` (fill) + `on-surface-variant` + (label) tokens on both platforms — tune disabled contrast by adjusting + those two tokens, not per-component. +- Buttons render their variant token solid; for a softer tonal fill set + opacity on the token itself (e.g. `'secondary' => 'fuchsia-500/70'`). +- `` accepts platform enum overrides as attributes — + `:ios="Ios::House"` / `:android="Android::Home"` — matching the + programmatic `Icon::make(ios: …, android: …)`. + +@verbatim + +// config/native-ui.php +'light' => [ + 'primary' => 'violet-600', // tailwind palette name + 'secondary' => 'fuchsia-500/70', // with opacity → tonal fills + 'surface' => '#F8FAFC', // plain hex + 'accent' => '#00AAA680', // CSS alpha hex (#RRGGBBAA) +], + +@endverbatim + ### Typography - **Custom fonts.** Drop `.ttf`/`.otf`/`.ttc` files into the app's @@ -39,6 +75,13 @@ (iOS registers them by PostScript name, Android loads from `assets/fonts/`); an unresolved name falls back to the system font. Font size/weight still come from `text-*` / `font-*` classes and the theme. +- **Downloading fonts.** `php artisan native:font Lobster` (or `"Rock Salt"`, + multiple families, `--weights=400,700`, `--italic`) downloads Google Fonts + into `resources/fonts/` with ready-to-use token names — no API key. +- **App-wide default font.** Set the theme's `font-family` token in + `config/native-ui.php` to a bundled token (e.g. `'Inter-Regular'`) to apply + it everywhere; per-element `font` attributes and `font-serif`/`font-mono` + classes still win. `native:font --default` sets it for you. - **Line height (leading).** `leading-none|tight|snug|normal|relaxed|loose` (unitless multipliers of the font size), plus arbitrary `leading-[1.4]` (multiplier) and `leading-[24px]` (absolute). Applies to `` and diff --git a/resources/ios/NativeUIButtonRenderer.swift b/resources/ios/NativeUIButtonRenderer.swift index 085e433..b01201f 100644 --- a/resources/ios/NativeUIButtonRenderer.swift +++ b/resources/ios/NativeUIButtonRenderer.swift @@ -4,7 +4,9 @@ import SwiftUI /// /// Maps semantic `variant` prop to the matching SwiftUI button style: /// - primary → `.buttonStyle(.borderedProminent)` + `.tint(theme.primary)` -/// - secondary → `.buttonStyle(.bordered)` + `.tint(theme.secondary)` +/// - secondary → `.buttonStyle(.borderedProminent)` + `.tint(theme.secondary)` +/// (solid, like primary — for a tonal look set opacity on the token in +/// the theme config, e.g. `'secondary' => 'fuchsia-500/70'`) /// - destructive → `.buttonStyle(.borderedProminent)` + `.tint(theme.destructive)` /// - accent → `.buttonStyle(.borderedProminent)` + `.tint(theme.accent)` /// - ghost → `.buttonStyle(.plain)` + `.foregroundStyle(theme.primary)` @@ -108,7 +110,8 @@ struct NativeUIButtonRenderer: View { iconSize: metrics.iconSize, textSize: textSize, fontName: fontName.isEmpty ? nil : fontName, - lineSpacing: lineSpacing + lineSpacing: lineSpacing, + spinnerColor: theme.onSurfaceVariant ) // `.fillWidthIfRequested(node)` is applied INSIDE the Button's // label closure at each variant call site below. SwiftUI's @@ -241,12 +244,21 @@ struct NativeUIButtonRenderer: View { a11yLabel: String, a11yHint: String ) -> some View { + // Disabled state (all variants): `surface-variant` fill + + // `on-surface-variant` label from the theme. Explicit tints and + // `.foregroundStyle` persist through `.disabled()` — the system + // only dims, which left e.g. a white label on a pale fill. Android + // uses the same token pair, so disabled looks identical there. switch variant { case "secondary": + // Solid fill of the secondary token, same treatment as primary. + // No renderer-imposed alpha: transparency belongs to the theme + // config (e.g. `'secondary' => 'fuchsia-500/70'`). `.bordered` + // would render the tint at ~15% opacity and lose the label. Button(action: action) { content.fillWidthIfRequested(node) } - .buttonStyle(.bordered) - .tint(theme.secondary) - .foregroundStyle(theme.onSecondary) + .buttonStyle(.borderedProminent) + .tint(enabled ? theme.secondary : theme.surfaceVariant) + .foregroundStyle(enabled ? theme.onSecondary : theme.onSurfaceVariant) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -258,8 +270,8 @@ struct NativeUIButtonRenderer: View { // color rather than the theme's destructive token. Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.borderedProminent) - .tint(theme.destructive) - .foregroundStyle(theme.onDestructive) + .tint(enabled ? theme.destructive : theme.surfaceVariant) + .foregroundStyle(enabled ? theme.onDestructive : theme.onSurfaceVariant) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -268,7 +280,7 @@ struct NativeUIButtonRenderer: View { case "ghost": Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.plain) - .foregroundStyle(theme.primary) + .foregroundStyle(enabled ? theme.primary : theme.onSurfaceVariant) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -277,8 +289,8 @@ struct NativeUIButtonRenderer: View { case "accent": Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.borderedProminent) - .tint(theme.accent) - .foregroundStyle(theme.onAccent) + .tint(enabled ? theme.accent : theme.surfaceVariant) + .foregroundStyle(enabled ? theme.onAccent : theme.onSurfaceVariant) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -287,8 +299,8 @@ struct NativeUIButtonRenderer: View { default: // "primary" and any unknown value Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.borderedProminent) - .tint(theme.primary) - .foregroundStyle(theme.onPrimary) + .tint(enabled ? theme.primary : theme.surfaceVariant) + .foregroundStyle(enabled ? theme.onPrimary : theme.onSurfaceVariant) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -349,12 +361,18 @@ private struct ButtonContent: View { let textSize: CGFloat var fontName: String? = nil var lineSpacing: CGFloat = 0 + var spinnerColor: Color? = nil var body: some View { HStack(spacing: 8) { if loading { + // Explicit tint: the spinner follows the Button's tint by + // default, which in the loading (= disabled) state is the + // pale surface-variant fill — invisible against itself. + // Matches Android, where the spinner uses the content color. ProgressView() .controlSize(.small) + .tint(spinnerColor) if !label.isEmpty { Text(label).nuiScaledFont(size: textSize, weight: .medium, fontName: fontName).lineSpacing(lineSpacing) } diff --git a/resources/ios/NativeUICheckboxRenderer.swift b/resources/ios/NativeUICheckboxRenderer.swift index b601bba..8aef8d7 100644 --- a/resources/ios/NativeUICheckboxRenderer.swift +++ b/resources/ios/NativeUICheckboxRenderer.swift @@ -41,6 +41,7 @@ struct NativeUICheckboxRenderer: View { .foregroundColor(checked ? theme.primary : theme.onSurfaceVariant) if !label.isEmpty { Text(label) + .nuiScaledFont(size: 17) .foregroundColor(theme.onSurface) } } diff --git a/resources/ios/NativeUIDrawerHost.swift b/resources/ios/NativeUIDrawerHost.swift index 79818e6..b9f5751 100644 --- a/resources/ios/NativeUIDrawerHost.swift +++ b/resources/ios/NativeUIDrawerHost.swift @@ -15,6 +15,13 @@ func registerNativeUIChrome() { let overlayNode = root.children.first { $0.type == "floating_overlay" } return AnyView(NativeFloatingOverlayHost(overlayNode: overlayNode) { content }) } + + // Resolve chrome font tokens (per-layout / per-bar `font_name` props on + // the root sentinels) for core's chrome renderers — bundle lookup + + // CoreText registration + PostScript naming is this plugin's knowledge. + NativeChromeFontResolver.resolvePostScriptName = { token in + NativeUIFontResolver.postScriptName(for: token) + } } /// Global open/close state for the content-agnostic side drawer diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift index d561b26..c50f534 100644 --- a/resources/ios/NativeUIListItemRenderer.swift +++ b/resources/ios/NativeUIListItemRenderer.swift @@ -67,15 +67,15 @@ struct NativeUIListItemRenderer: View { VStack(alignment: .leading, spacing: 2) { if !overline.isEmpty { Text(overline) - .font(.caption) + .nuiScaledFont(size: 12) .foregroundColor(overlineColor != 0 ? Color(argb: overlineColor) : .secondary) } Text(headline) - .font(.body) + .nuiScaledFont(size: 17) .foregroundColor(headlineColor != 0 ? Color(argb: headlineColor) : .primary) if !supporting.isEmpty { Text(supporting) - .font(.subheadline) + .nuiScaledFont(size: 15) .foregroundColor(supportingColor != 0 ? Color(argb: supportingColor) : .secondary) } } @@ -194,6 +194,7 @@ struct NativeUIListItemRenderer: View { .foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary) case "text": Text(value) + .nuiScaledFont(size: 17) .foregroundColor(textColor != 0 ? Color(argb: textColor) : .secondary) case "icon_button": // Spoken name for the icon-only trailing button: explicit diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index d3739f0..9e7c13b 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -71,9 +71,9 @@ struct NativeUIListRenderer: View { } } } header: { - if !header.isEmpty { Text(header) } + if !header.isEmpty { Text(header).nuiScaledFont(size: 13) } } footer: { - if !footer.isEmpty { Text(footer) } + if !footer.isEmpty { Text(footer).nuiScaledFont(size: 13) } } } else { rowView(child, separator: separator) diff --git a/resources/ios/NativeUINavRenderers.swift b/resources/ios/NativeUINavRenderers.swift index 99b8b28..092417a 100644 --- a/resources/ios/NativeUINavRenderers.swift +++ b/resources/ios/NativeUINavRenderers.swift @@ -17,6 +17,9 @@ struct NativeUITopBarRenderer: View { let backgroundColor: Color = bgColorArgb != 0 ? Color(argb: bgColorArgb) : .clear let elevation = CGFloat(props.getFloat("elevation")) let showBack = props.getBool("show_navigation_icon") + // Per-bar font (font_name prop) — nil falls through to the theme + // default inside nuiScaledFont. + let barFont = props.getString("font_name") HStack(spacing: 8) { // Leading: back button (system back, not a press callback — @@ -37,11 +40,11 @@ struct NativeUITopBarRenderer: View { VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.headline) + .nuiScaledFont(size: 17, weight: .semibold, fontName: barFont.isEmpty ? nil : barFont) .foregroundColor(textColor) if !subtitle.isEmpty { Text(subtitle) - .font(.caption) + .nuiScaledFont(size: 12, fontName: barFont.isEmpty ? nil : barFont) .foregroundColor(textColor.opacity(0.7)) } } @@ -114,6 +117,9 @@ struct NativeUIBottomNavRenderer: View { // when each item's label text is shown. let activeArgb = node.props.getColor("active_color", default: 0) let activeColor: Color = activeArgb != 0 ? Color(argb: activeArgb) : .accentColor + // Per-bar font (font_name prop) — nil falls through to the theme + // default inside nuiScaledFont. + let barFont = node.props.getString("font_name") let isDark = node.props.getBool("dark") // Explicit `textColor()` from the TabBar builder wins for inactive // items; falls back to the gray defaults picked by `dark()`. @@ -180,7 +186,7 @@ struct NativeUIBottomNavRenderer: View { } if showLabel && !label.isEmpty { Text(label) - .font(.caption2) + .nuiScaledFont(size: 11, fontName: barFont.isEmpty ? nil : barFont) } } .foregroundColor(active ? activeColor : inactiveColor) diff --git a/resources/ios/NativeUIRadioGroupRenderer.swift b/resources/ios/NativeUIRadioGroupRenderer.swift index 6a5392d..efa653f 100644 --- a/resources/ios/NativeUIRadioGroupRenderer.swift +++ b/resources/ios/NativeUIRadioGroupRenderer.swift @@ -87,7 +87,7 @@ struct NativeUIRadioRenderer: View { .nuiScaledFont(size: 22) .foregroundColor(isSelected ? theme.primary : theme.onSurfaceVariant) if !label.isEmpty { - Text(label).foregroundColor(theme.onSurface) + Text(label).nuiScaledFont(size: 17).foregroundColor(theme.onSurface) } } .nuiMinTapTarget() diff --git a/resources/ios/NativeUISelectRenderer.swift b/resources/ios/NativeUISelectRenderer.swift index dcc757d..efc86bc 100644 --- a/resources/ios/NativeUISelectRenderer.swift +++ b/resources/ios/NativeUISelectRenderer.swift @@ -46,6 +46,7 @@ struct NativeUISelectRenderer: View { } label: { HStack { Text(selected.isEmpty ? placeholder : selected) + .nuiScaledFont(size: 17) .foregroundStyle(selected.isEmpty ? theme.onSurfaceVariant : theme.onSurface) Spacer() Image(systemName: "chevron.up.chevron.down") diff --git a/resources/ios/NativeUITextRenderer.swift b/resources/ios/NativeUITextRenderer.swift index 38dab16..bbd3e04 100644 --- a/resources/ios/NativeUITextRenderer.swift +++ b/resources/ios/NativeUITextRenderer.swift @@ -4,6 +4,7 @@ struct NativeUITextRenderer: View { let node: NativeUINode @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var themeStore = NativeUITheme.shared var body: some View { // A text node with child text runs composes them into ONE wrapping @@ -178,9 +179,18 @@ struct NativeUITextRenderer: View { var run = AttributedString(applyTransform(text, ctx.textTransform)) let runWeight = resolveFontWeight(ctx.fontWeightInt) + // Explicit run font first, then the app-wide theme default (only for + // the default design — font-serif/font-mono win over the default), + // then the system font. Mirrors NUIScaledFontModifier's resolution. + var runFontName = ctx.fontName + if runFontName.isEmpty, ctx.fontFamilyInt == 0 { + let family = themeStore.resolve(for: colorScheme).fontFamily + if !family.isEmpty, family != "System" { runFontName = family } + } + var font: Font - if !ctx.fontName.isEmpty, - let custom = NativeUIFontResolver.font(ctx.fontName, size: CGFloat(ctx.fontSize)) { + if !runFontName.isEmpty, + let custom = NativeUIFontResolver.font(runFontName, size: CGFloat(ctx.fontSize)) { font = custom.weight(runWeight) } else { font = Font.system( diff --git a/resources/ios/NativeUITheme.swift b/resources/ios/NativeUITheme.swift index 1dcc005..29d64c8 100644 --- a/resources/ios/NativeUITheme.swift +++ b/resources/ios/NativeUITheme.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit // MARK: - Theme Tokens @@ -131,6 +132,32 @@ final class NativeUITheme: ObservableObject { // assignment on actual change to keep the observable stable. if newLight != self.light { self.light = newLight } if newDark != self.dark { self.dark = newDark } + + applyChromeFont(fontFamily) + } + + /// Font the SYSTEM-drawn navigation chrome. `navigationTitle` renders + /// through UIKit's `UINavigationBar` and SwiftUI exposes no font API for + /// it, so route the app-wide default font through the UIKit appearance + /// proxy. Core doesn't configure `UINavigationBar` appearances itself, so + /// this composes safely; bars pick it up on creation (theme lands at boot, + /// before the first screen). Tab-bar labels are excluded — core's tabs + /// renderer manages its own `UITabBarAppearance`. + private func applyChromeFont(_ family: String) { + if family.isEmpty || family == "System" { + UINavigationBar.appearance().titleTextAttributes = nil + UINavigationBar.appearance().largeTitleTextAttributes = nil + return + } + + guard let psName = NativeUIFontResolver.postScriptName(for: family) else { return } + + if let title = UIFont(name: psName, size: 17) { + UINavigationBar.appearance().titleTextAttributes = [.font: title] + } + if let large = UIFont(name: psName, size: 34) { + UINavigationBar.appearance().largeTitleTextAttributes = [.font: large] + } } } @@ -183,17 +210,31 @@ struct NUIScaledFontModifier: ViewModifier { self.fontName = fontName } + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + func body(content: Content) -> some View { // A resolvable custom font wins; the weight still applies (SwiftUI // selects/synthesizes it within the family). Unknown names — or none — // fall back to the system font unchanged. `size` is already Dynamic- // Type-scaled by `@ScaledMetric`, so the custom font uses it as-is. - if let fontName, let custom = NativeUIFontResolver.font(fontName, size: size) { + if let name = effectiveFontName, let custom = NativeUIFontResolver.font(name, size: size) { content.font(custom.weight(weight)) } else { content.font(.system(size: size, weight: weight, design: design)) } } + + /// Explicit per-element font first; otherwise the app-wide default from + /// the theme's `font-family` token — but only for the default design, so + /// explicit `font-serif` / `font-mono` classes still win over the default. + private var effectiveFontName: String? { + if let fontName { return fontName } + guard design == .default else { return nil } + + let family = themeStore.resolve(for: colorScheme).fontFamily + return (family.isEmpty || family == "System") ? nil : family + } } extension View { diff --git a/src/Concerns/ResolvesColorValues.php b/src/Concerns/ResolvesColorValues.php new file mode 100644 index 0000000..ad8615c --- /dev/null +++ b/src/Concerns/ResolvesColorValues.php @@ -0,0 +1,29 @@ +-