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 @@
+-