From 24cd343c48578a09986132a792a8770d242b345a Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 12:01:04 -0400 Subject: [PATCH 1/8] fix(progress-bar): accept the documented track-color attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyAttributes only matched trackColor, but attributes reach elements verbatim and the docs use track-color — the documented spelling was silently dropped. Accept both, matching every other element. Co-Authored-By: Claude Fable 5 --- src/Elements/ProgressBar.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Elements/ProgressBar.php b/src/Elements/ProgressBar.php index 6b68809..c18dab8 100644 --- a/src/Elements/ProgressBar.php +++ b/src/Elements/ProgressBar.php @@ -33,8 +33,10 @@ public function applyAttributes(array $attrs): void if (! empty($attrs['indeterminate'])) { $this->indeterminate(); } - if (isset($attrs['color'])) { $this->color((string) $attrs['color']); } - if (isset($attrs['trackColor'])) { $this->trackColor((string) $attrs['trackColor']); } + if (isset($attrs['color'])) { $this->color((string) $attrs['color']); } + + $trackColor = $attrs['track-color'] ?? $attrs['trackColor'] ?? null; + if ($trackColor !== null) { $this->trackColor((string) $trackColor); } $this->applyA11yAttributes($attrs); } From 2e11f1375f4285606d7c646bb88afcdb14aed40c Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 16:33:51 -0400 Subject: [PATCH 2/8] fix: accept kebab-case line-height attributes on button and text inputs line-height / line-height-px now work alongside the camelCase forms, matching how every other attribute on these elements is matched. Co-Authored-By: Claude Fable 5 --- src/Elements/BaseTextInput.php | 6 ++++-- src/Elements/Button.php | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 7549bc1..90e51b7 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -95,8 +95,10 @@ public function applyAttributes(array $attrs): void if (isset($attrs['font'])) { $this->font($attrs['font']); } // Line height (leading) — meaningful for multiline inputs. `line_height` // is a multiplier of font size; `line_height_px` an absolute override. - if (isset($attrs['lineHeight'])) { $this->inputProps['line_height'] = (float) $attrs['lineHeight']; } - if (isset($attrs['lineHeightPx'])) { $this->inputProps['line_height_px'] = (float) $attrs['lineHeightPx']; } + $lineHeight = $attrs['line-height'] ?? $attrs['lineHeight'] ?? null; + if ($lineHeight !== null) { $this->inputProps['line_height'] = (float) $lineHeight; } + $lineHeightPx = $attrs['line-height-px'] ?? $attrs['lineHeightPx'] ?? null; + if ($lineHeightPx !== null) { $this->inputProps['line_height_px'] = (float) $lineHeightPx; } $this->applyA11yAttributes($attrs); // Sync mode + debounce (from `native:model` expansion, or set manually). diff --git a/src/Elements/Button.php b/src/Elements/Button.php index 20cfd87..0700613 100644 --- a/src/Elements/Button.php +++ b/src/Elements/Button.php @@ -83,11 +83,13 @@ public function applyAttributes(array $attrs): void // Line height (leading). `line_height` is a multiplier of font size; // `line_height_px` an absolute override. Button labels are single-line, // so this is accepted for parity but rarely has a visible effect. - if (isset($attrs['lineHeight'])) { - $this->buttonProps['line_height'] = (float) $attrs['lineHeight']; + $lineHeight = $attrs['line-height'] ?? $attrs['lineHeight'] ?? null; + if ($lineHeight !== null) { + $this->buttonProps['line_height'] = (float) $lineHeight; } - if (isset($attrs['lineHeightPx'])) { - $this->buttonProps['line_height_px'] = (float) $attrs['lineHeightPx']; + $lineHeightPx = $attrs['line-height-px'] ?? $attrs['lineHeightPx'] ?? null; + if ($lineHeightPx !== null) { + $this->buttonProps['line_height_px'] = (float) $lineHeightPx; } $this->applyA11yAttributes($attrs); From 5ac0485e8783dbe614d7d65e659927712178c2fd Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 21:32:36 -0400 Subject: [PATCH 3/8] Theme: accept the full color grammar in config tokens Light/dark color tokens now resolve through core's TailwindParser::resolveColorValue() on load/merge, so the config accepts Tailwind palette names ('violet-600'), opacity modifiers ('red-300/20', '#8B5CF6/50'), and CSS alpha hex ('#8B5CF680') alongside plain hex. Unrecognized strings pass through untouched. Dark-mode auto-derivation (invertLuminance) now carries the alpha byte through instead of skipping 8-digit values. Co-Authored-By: Claude Fable 5 --- config/native-ui.php | 5 +++ src/Theme.php | 55 +++++++++++++++++++++++--- tests/ThemeColorTest.php | 84 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 tests/ThemeColorTest.php diff --git a/config/native-ui.php b/config/native-ui.php index 975fbc1..2e2328f 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. | diff --git a/src/Theme.php b/src/Theme.php index 5bc1ba4..b9af890 100644 --- a/src/Theme.php +++ b/src/Theme.php @@ -2,6 +2,8 @@ namespace Nativephp\NativeUi; +use Native\Mobile\Edge\TailwindParser; + /** * Native UI — Theme token storage. * @@ -25,7 +27,7 @@ class Theme */ public static function load(array $tokens): void { - static::$tokens = $tokens; + static::$tokens = static::normalizeColors($tokens); static::pushToNative(); } @@ -35,7 +37,7 @@ public static function load(array $tokens): void */ public static function merge(array $tokens): void { - static::$tokens = static::deepMerge(static::$tokens, $tokens); + static::$tokens = static::deepMerge(static::$tokens, static::normalizeColors($tokens)); static::pushToNative(); } @@ -112,6 +114,38 @@ public static function pushToNative(): void // ─── Internals ──────────────────────────────────────────────────────────── + /** + * Resolve authored color tokens in the `light` / `dark` blocks to + * wire-format hex. Accepts Tailwind palette names (`red-300`), opacity + * modifiers (`red-300/20`, `#8B5CF6/50`), and CSS hex with alpha + * (`#8B5CF680`) — see TailwindParser::resolveColorValue for the full + * grammar. Unrecognized strings pass through untouched. + * + * Only the two color blocks are touched; radii, font sizes, and + * `font-family` never enter the resolver. method_exists guards against + * a core version that predates the shared resolver. + */ + private static function normalizeColors(array $tokens): array + { + if (! method_exists(TailwindParser::class, 'resolveColorValue')) { + return $tokens; + } + + foreach (['light', 'dark'] as $mode) { + if (! isset($tokens[$mode]) || ! is_array($tokens[$mode])) { + continue; + } + foreach ($tokens[$mode] as $key => $value) { + if (! is_string($value)) { + continue; + } + $tokens[$mode][$key] = TailwindParser::resolveColorValue($value) ?? $value; + } + } + + return $tokens; + } + private static function deepMerge(array $base, array $overlay): array { foreach ($overlay as $key => $value) { @@ -126,14 +160,22 @@ private static function deepMerge(array $base, array $overlay): array } /** - * Invert lightness (HSL) of a #RRGGBB color. Preserves hue and saturation - * so brand colors remain recognizable in dark mode. + * Invert lightness (HSL) of a #RRGGBB or #AARRGGBB color. Preserves hue + * and saturation so brand colors remain recognizable in dark mode; a + * leading alpha byte (wire format) is carried over unchanged. */ private static function invertLuminance(string $hex): string { $hex = ltrim($hex, '#'); + + $alpha = ''; + if (strlen($hex) === 8) { + $alpha = strtoupper(substr($hex, 0, 2)); + $hex = substr($hex, 2); + } + if (strlen($hex) !== 6) { - return '#'.$hex; + return '#'.$alpha.$hex; } $r = hexdec(substr($hex, 0, 2)) / 255.0; @@ -147,7 +189,8 @@ private static function invertLuminance(string $hex): string [$r2, $g2, $b2] = static::hslToRgb($h, $s, $l); return sprintf( - '#%02X%02X%02X', + '#%s%02X%02X%02X', + $alpha, (int) round($r2 * 255), (int) round($g2 * 255), (int) round($b2 * 255) diff --git a/tests/ThemeColorTest.php b/tests/ThemeColorTest.php new file mode 100644 index 0000000..a705d13 --- /dev/null +++ b/tests/ThemeColorTest.php @@ -0,0 +1,84 @@ +mute(); + Theme::reset(); +}); + +afterEach(function () { + Theme::reset(); +}); + +describe('Theme color token normalization', function () { + it('resolves tailwind palette names', function () { + Theme::load(['light' => ['primary' => 'red-300', 'accent' => 'orange-800']]); + + expect(Theme::get('light.primary'))->toBe('#FCA5A5'); + expect(Theme::get('light.accent'))->toBe('#9A3412'); + }); + + it('resolves opacity modifiers on names and hex', function () { + Theme::load(['light' => [ + 'primary' => 'red-300/20', + 'accent' => '#8B5CF6/50', + ]]); + + expect(Theme::get('light.primary'))->toBe('#33FCA5A5'); + expect(Theme::get('light.accent'))->toBe('#808B5CF6'); + }); + + it('converts CSS alpha hex (#RRGGBBAA) to wire ARGB order', function () { + Theme::load(['light' => ['primary' => '#8B5CF680']]); + + expect(Theme::get('light.primary'))->toBe('#808B5CF6'); + }); + + it('passes plain hex and unrecognized strings through untouched', function () { + Theme::load(['light' => [ + 'primary' => '#B91C1C', + 'accent' => 'not-a-color', + ]]); + + expect(Theme::get('light.primary'))->toBe('#B91C1C'); + expect(Theme::get('light.accent'))->toBe('not-a-color'); + }); + + it('normalizes tokens supplied via merge()', function () { + Theme::load(['light' => ['primary' => '#B91C1C']]); + Theme::merge(['light' => ['accent' => 'orange-800/50']]); + + expect(Theme::get('light.primary'))->toBe('#B91C1C'); + expect(Theme::get('light.accent'))->toBe('#809A3412'); + }); + + it('normalizes explicit dark tokens', function () { + Theme::load([ + 'light' => ['primary' => 'red-300'], + 'dark' => ['primary' => 'red-800'], + ]); + + expect(Theme::get('dark.primary'))->toBe('#991B1B'); + }); + + it('auto-derives dark tokens from normalized palette names', function () { + Theme::load(['light' => ['primary' => 'red-300']]); + + $dark = Theme::get('dark.primary'); + + expect($dark)->toMatch('/^#[0-9A-F]{6}$/'); + expect($dark)->not->toBe('#FCA5A5'); + }); + + it('preserves the alpha byte when auto-deriving dark tokens', function () { + Theme::load(['light' => ['primary' => '#8B5CF680']]); + + // Wire format is #AARRGGBB — derived dark keeps the authored alpha. + expect(Theme::get('dark.primary'))->toMatch('/^#80[0-9A-F]{6}$/'); + }); +}); From 9313053916ab0cc0891fbe1d1aa581d9100898ef Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 21:32:49 -0400 Subject: [PATCH 4/8] Elements: resolve the color grammar in color props; icon enum attrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every element color prop — ActivityIndicator/BareTextInput/Icon/ ProgressBar color setters, ListItem's styling setters, monogram and icon-tile colors, and the badge color / swipe-action tint payloads — now routes through the shared ResolvesColorValues trait, so 'red-300', 'red-300/20', and '#8B5CF680' work everywhere hex did. Unrecognized strings still pass through to the native fallbacks. Icon also accepts :ios / :android platform enum attributes in blade, matching the programmatic Icon::make(ios: …, android: …) shape. Co-Authored-By: Claude Fable 5 --- src/Concerns/ResolvesColorValues.php | 29 +++++++ src/Elements/ActivityIndicator.php | 4 +- src/Elements/BareTextInput.php | 6 +- src/Elements/Icon.php | 14 +++- src/Elements/ListItem.php | 26 ++++--- src/Elements/ProgressBar.php | 6 +- tests/ElementColorTest.php | 111 +++++++++++++++++++++++++++ tests/IconEnumAttrTest.php | 69 +++++++++++++++++ 8 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 src/Concerns/ResolvesColorValues.php create mode 100644 tests/ElementColorTest.php create mode 100644 tests/IconEnumAttrTest.php 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 @@ +indicatorProps['color'] = $hex; + $this->indicatorProps['color'] = $this->resolveColorValue($hex); return $this; } diff --git a/src/Elements/BareTextInput.php b/src/Elements/BareTextInput.php index a1f45c9..4f738a3 100644 --- a/src/Elements/BareTextInput.php +++ b/src/Elements/BareTextInput.php @@ -2,6 +2,8 @@ namespace Nativephp\NativeUi\Elements; +use Nativephp\NativeUi\Concerns\ResolvesColorValues; + /** * Chromeless text input — a SwiftUI `TextField` (iOS) / Compose * `BasicTextField` (Android) with no outline, no label, no fill, no @@ -19,6 +21,8 @@ */ class BareTextInput extends BaseTextInput { + use ResolvesColorValues; + protected string $type = 'bare_text_input'; /** @@ -53,7 +57,7 @@ public function color(string $color): static // collector's `buildDarkProps` automatically maps `dark.color` // to `dark_color` for free — `class="text-slate-700 dark:text-slate-300"` // gives a working light/dark pair without any custom plumbing. - $this->inputProps['color'] = $color; + $this->inputProps['color'] = $this->resolveColorValue($color); return $this; } diff --git a/src/Elements/Icon.php b/src/Elements/Icon.php index e66ea41..faa26f4 100644 --- a/src/Elements/Icon.php +++ b/src/Elements/Icon.php @@ -7,9 +7,11 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Concerns\ResolvesColorValues; class Icon extends Element { + use ResolvesColorValues; protected string $type = 'icon'; @@ -36,6 +38,14 @@ public function applyAttributes(array $attrs): void if (isset($attrs['size'])) { $this->size((float) $attrs['size']); } if (isset($attrs['color'])) { $this->color($attrs['color']); } + // Platform enum overrides — `` + // — same shape as the programmatic `Icon::make(ios: …, android: …)`. + $ios = $attrs['ios'] ?? null; + $android = $attrs['android'] ?? null; + if ($ios !== null || $android !== null) { + $this->name(ios: $ios, android: $android); + } + // Optional dark-mode override hex. Renderers pick this when the // system colorScheme is dark; otherwise they use `color`. if (isset($attrs['dark-color']) || isset($attrs['darkColor'])) { @@ -77,14 +87,14 @@ public function size(float $size): static public function color(string $color): static { - $this->iconProps['color'] = $color; + $this->iconProps['color'] = $this->resolveColorValue($color); return $this; } public function darkColor(string $color): static { - $this->iconProps['dark_color'] = $color; + $this->iconProps['dark_color'] = $this->resolveColorValue($color); return $this; } diff --git a/src/Elements/ListItem.php b/src/Elements/ListItem.php index 52bb61a..a20e712 100644 --- a/src/Elements/ListItem.php +++ b/src/Elements/ListItem.php @@ -8,9 +8,11 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Concerns\ResolvesColorValues; class ListItem extends Element { + use ResolvesColorValues; protected string $type = 'list_item'; @@ -309,7 +311,7 @@ public function leadingMonogram(string $initials, ?string $color = null): static $this->listItemProps['leading_type'] = 'monogram'; $this->listItemProps['leading_value'] = substr($initials, 0, 2); if ($color !== null) { - $this->listItemProps['leading_monogram_color'] = $color; + $this->listItemProps['leading_monogram_color'] = $this->resolveColorValue($color); } return $this; @@ -330,7 +332,7 @@ public function leadingImage(string $url): static */ public function leadingIconBackgroundColor(string $color): static { - $this->listItemProps['leading_icon_bg_color'] = $color; + $this->listItemProps['leading_icon_bg_color'] = $this->resolveColorValue($color); return $this; } @@ -453,49 +455,49 @@ public function onTrailingPress(string $method): static public function headlineColor(string $color): static { - $this->listItemProps['headline_color'] = $color; + $this->listItemProps['headline_color'] = $this->resolveColorValue($color); return $this; } public function supportingColor(string $color): static { - $this->listItemProps['supporting_color'] = $color; + $this->listItemProps['supporting_color'] = $this->resolveColorValue($color); return $this; } public function overlineColor(string $color): static { - $this->listItemProps['overline_color'] = $color; + $this->listItemProps['overline_color'] = $this->resolveColorValue($color); return $this; } public function containerColor(string $color): static { - $this->listItemProps['container_color'] = $color; + $this->listItemProps['container_color'] = $this->resolveColorValue($color); return $this; } public function leadingIconColor(string $color): static { - $this->listItemProps['leading_icon_color'] = $color; + $this->listItemProps['leading_icon_color'] = $this->resolveColorValue($color); return $this; } public function trailingIconColor(string $color): static { - $this->listItemProps['trailing_icon_color'] = $color; + $this->listItemProps['trailing_icon_color'] = $this->resolveColorValue($color); return $this; } public function trailingTextColor(string $color): static { - $this->listItemProps['trailing_text_color'] = $color; + $this->listItemProps['trailing_text_color'] = $this->resolveColorValue($color); return $this; } @@ -576,10 +578,11 @@ private function serializeBadges(array $badges): string if (empty($resolved['icon'])) { continue; } + $color = $badge['color'] ?? ''; $out[] = [ 'icon' => $resolved['icon'], 'icon_variant' => $resolved['variant'] ?? '', - 'color' => $badge['color'] ?? '', + 'color' => $color === '' ? '' : $this->resolveColorValue($color), ]; } @@ -615,12 +618,13 @@ private function serializeActions(array $actions, CallbackRegistry $registry): s $action['android'] ?? null, ); + $tint = $action['tint'] ?? ''; $out[] = [ 'cb' => $registry->register($action['method']), 'label' => $action['label'] ?? '', 'icon' => $resolved['icon'] ?? '', 'icon_variant' => $resolved['variant'] ?? '', - 'tint' => $action['tint'] ?? '', + 'tint' => $tint === '' ? '' : $this->resolveColorValue($tint), 'role' => $action['role'] ?? '', ]; } diff --git a/src/Elements/ProgressBar.php b/src/Elements/ProgressBar.php index c18dab8..47e5b56 100644 --- a/src/Elements/ProgressBar.php +++ b/src/Elements/ProgressBar.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\ResolvesColorValues; /** * Linear progress bar. Value in [0.0, 1.0]. Omit `value` for indeterminate @@ -14,6 +15,7 @@ */ class ProgressBar extends Element { + use ResolvesColorValues; protected string $type = 'progress_bar'; @@ -48,14 +50,14 @@ public function applyAttributes(array $attrs): void */ public function color(string $hex): static { - $this->progressBarProps['color'] = $hex; + $this->progressBarProps['color'] = $this->resolveColorValue($hex); return $this; } public function trackColor(string $hex): static { - $this->progressBarProps['track_color'] = $hex; + $this->progressBarProps['track_color'] = $this->resolveColorValue($hex); return $this; } diff --git a/tests/ElementColorTest.php b/tests/ElementColorTest.php new file mode 100644 index 0000000..19ed06d --- /dev/null +++ b/tests/ElementColorTest.php @@ -0,0 +1,111 @@ +toArray(new CallbackRegistry)['props']; +} + +it('resolves palette names on progress bar colors', function () { + $props = collectProps('progress_bar', [ + 'color' => 'red-300', + 'track-color' => 'red-300/20', + ]); + + expect($props['color'])->toBe('#FCA5A5'); + expect($props['track_color'])->toBe('#33FCA5A5'); +}); + +it('resolves CSS alpha hex on icon colors', function () { + $props = collectProps('icon', [ + 'name' => 'home', + 'color' => '#8B5CF680', + 'dark-color' => 'violet-300/50', + ]); + + expect($props['color'])->toBe('#808B5CF6'); + expect($props['dark_color'])->toBe('#80C4B5FD'); +}); + +it('resolves activity indicator and bare input colors', function () { + expect(collectProps('activity_indicator', ['color' => 'teal-700'])['color']) + ->toBe('#0F766E'); + + expect(collectProps('bare_text_input', ['color' => 'slate-700'])['color']) + ->toBe('#334155'); +}); + +it('resolves list item color props', function () { + $props = collectProps('list_item', [ + 'headline' => 'Inbox', + 'headlineColor' => 'red-300', + 'containerColor' => '#8B5CF6/50', + 'leadingIconBgColor' => 'orange-800', + ]); + + expect($props['headline_color'])->toBe('#FCA5A5'); + expect($props['container_color'])->toBe('#808B5CF6'); + expect($props['leading_icon_bg_color'])->toBe('#9A3412'); +}); + +it('resolves colors inside badge and swipe-action payloads', function () { + $props = collectProps('list_item', [ + 'headline' => 'Inbox', + 'trailing-badges' => [ + ['icon' => 'flag', 'color' => 'red-500'], + ['icon' => 'pin'], + ], + 'trailing-actions' => [ + ['method' => 'archive', 'label' => 'Archive', 'tint' => 'blue-500/50'], + ['method' => 'delete', 'label' => 'Delete'], + ], + ]); + + $badges = json_decode($props['trailing_badges_json'], true); + expect($badges[0]['color'])->toBe('#EF4444'); + expect($badges[1]['color'])->toBe(''); + + $actions = json_decode($props['trailing_actions_json'], true); + expect($actions[0]['tint'])->toBe('#803B82F6'); + expect($actions[1]['tint'])->toBe(''); +}); + +it('passes plain hex and unknown strings through untouched', function () { + $props = collectProps('progress_bar', ['color' => '#B91C1C']); + expect($props['color'])->toBe('#B91C1C'); + + $props = collectProps('icon', ['name' => 'home', 'color' => 'chartreuse']); + expect($props['color'])->toBe('chartreuse'); +}); diff --git a/tests/IconEnumAttrTest.php b/tests/IconEnumAttrTest.php new file mode 100644 index 0000000..910bca1 --- /dev/null +++ b/tests/IconEnumAttrTest.php @@ -0,0 +1,69 @@ +` — platform enum + * overrides as blade attributes, same shape as the programmatic + * `Icon::make(ios: …, android: …)`. Fixture enums are inline (distinct + * names from HasPlatformIconTest's — Pest loads all test files into one + * process) so the test doesn't depend on a generated `App\Icons\*` catalog. + */ +enum IconAttrIos: string implements IosSymbol +{ + case House = 'house.fill'; +} + +enum IconAttrAndroid: string implements AndroidSymbol +{ + public function variant(): string + { + return 'filled'; + } + + case Home = 'home'; +} + +beforeEach(function () { + NativeElementCollector::reset(); + ElementRegistry::reset(); + ElementRegistry::register('icon', Icon::class); +}); + +afterEach(function () { + NativeElementCollector::reset(); + ElementRegistry::reset(); + Platform::set(null); +}); + +it('resolves :ios enum attribute on ios', function () { + Platform::set('ios'); + + NativeElementCollector::leaf('icon', [ + 'ios' => IconAttrIos::House, + 'android' => IconAttrAndroid::Home, + 'size' => 36, + ]); + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['name'])->toBe('house.fill'); +}); + +it('resolves :android enum attribute on android', function () { + Platform::set('android'); + + NativeElementCollector::leaf('icon', [ + 'ios' => IconAttrIos::House, + 'android' => IconAttrAndroid::Home, + ]); + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['name'])->toBe('home'); + expect($tree['props']['material_variant'])->toBe('filled'); +}); From d4910d22cecf7386c85c6bc2a0deb149254d608d Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 21:33:52 -0400 Subject: [PATCH 5/8] Buttons: solid secondary + theme-token disabled state, both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secondary: iOS used `.bordered` (renders its tint at ~15% opacity — the fuchsia token washed to pale pink under a white onSecondary label) and Android baked in a 0.7 tonal alpha. Both now fill the secondary token solid like every other variant; a tonal look is authored on the token itself ('secondary' => 'fuchsia-500/70'). Disabled/loading: both platforms now draw surface-variant fill + on-surface-variant label instead of the platform defaults (M3's onSurface-at-38%; iOS's system gray under a persisted explicit white foregroundStyle). The iOS loading spinner gets an explicit tint so it no longer inherits the pale disabled fill and vanishes. Co-Authored-By: Claude Fable 5 --- resources/android/ButtonRenderer.kt | 25 +++++++++---- resources/ios/NativeUIButtonRenderer.swift | 42 +++++++++++++++------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/resources/android/ButtonRenderer.kt b/resources/android/ButtonRenderer.kt index f3c82b1..7c47b49 100644 --- a/resources/android/ButtonRenderer.kt +++ b/resources/android/ButtonRenderer.kt @@ -112,19 +112,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 +141,8 @@ object ButtonRenderer { colors = ButtonDefaults.buttonColors( containerColor = theme.destructive, contentColor = theme.onDestructive, + disabledContainerColor = theme.surfaceVariant, + disabledContentColor = theme.onSurfaceVariant, ), content = { content() }, ) @@ -146,7 +152,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 +168,8 @@ object ButtonRenderer { colors = ButtonDefaults.buttonColors( containerColor = theme.primary, contentColor = theme.onPrimary, + disabledContainerColor = theme.surfaceVariant, + disabledContentColor = theme.onSurfaceVariant, ), content = { content() }, ) 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) } From 29452aa06ae52ba28615bef882558647ff3b950b Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 21:34:09 -0400 Subject: [PATCH 6/8] Docs: theming & color grammar (README + boost guidelines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the shared authoring grammar — Tailwind palette names, /N opacity modifiers, CSS #RRGGBBAA alpha hex — for theme tokens, element color props, and arbitrary classes; the surface-variant / on-surface-variant disabled-state tokens; and blade enum attributes. Co-Authored-By: Claude Fable 5 --- README.md | 30 ++++++++++++++++ resources/boost/guidelines/core.blade.php | 43 +++++++++++++++++++++++ 2 files changed, 73 insertions(+) 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/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 From 1824ce34bf020153b036d2b89c726ffdef667bda Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 21:49:03 -0400 Subject: [PATCH 7/8] feat(native-ui): apply the theme default font across all renderers and chrome The theme's font-family token (config/native-ui.php) was plumbed to the native stores but consumed by nothing. Every visible-text site now falls back to it, with precedence: element font attr > font-serif/font-mono > theme default > system. iOS: nuiScaledFont resolves the default inside NUIScaledFontModifier (theme-observing), the composed text-run path mirrors it, and NativeUITheme.apply() sets UINavigationBar appearance title fonts so system-drawn (large) titles follow the app default. 12 renderer sites converted from semantic fonts to nuiScaledFont (list items, list section headers/footers, checkbox/radio/select labels, nav titles/labels). Android: nuiThemeDefaultFontFamily/nuiDefaultFontFamily helpers + a fontFamily pass at 29 Text sites (badge, chips, checkboxes, radios, toggle, tabs, list + list items, select, input slots, nav). Core chrome fonts wholesale through the new NativeUIThemeProvider typography seam, populated with nuiThemeDefaultTypography (all 15 M3 styles, family-only swap); per-bar font_name props resolve through the fontFamilyResolver seam registered in registerNativeUIChrome. Intentionally skipped: SwiftUI Menu items and the modal close glyph (system-styled). Element-level font/leading serialization pinned in tests/FontTypographyTest.php. Co-Authored-By: Claude Fable 5 --- resources/android/BadgeRenderer.kt | 1 + resources/android/BareTextInputRenderer.kt | 3 +- resources/android/ButtonGroupRenderer.kt | 2 +- resources/android/ButtonRenderer.kt | 3 +- resources/android/CheckboxRenderer.kt | 2 +- resources/android/ChipRenderer.kt | 2 +- resources/android/ContainerRenderers.kt | 1 + resources/android/FilledTextInputRenderer.kt | 3 +- resources/android/ListItemRenderer.kt | 5 ++ resources/android/ListRenderer.kt | 3 + resources/android/NativeUIChromeInit.kt | 24 +++++- resources/android/NativeUIFontResolver.kt | 65 ++++++++++++++++ resources/android/NavRenderers.kt | 7 +- .../android/OutlinedTextInputRenderer.kt | 3 +- resources/android/RadioGroupRenderer.kt | 2 +- resources/android/RadioRenderer.kt | 2 +- resources/android/SelectRenderer.kt | 6 +- resources/android/TabRowRenderer.kt | 2 +- resources/android/TextInputShared.kt | 10 +-- resources/android/TextRenderer.kt | 7 +- resources/android/ToggleRenderer.kt | 2 +- resources/ios/NativeUICheckboxRenderer.swift | 1 + resources/ios/NativeUIDrawerHost.swift | 7 ++ resources/ios/NativeUIListItemRenderer.swift | 7 +- resources/ios/NativeUIListRenderer.swift | 4 +- resources/ios/NativeUINavRenderers.swift | 12 ++- .../ios/NativeUIRadioGroupRenderer.swift | 2 +- resources/ios/NativeUISelectRenderer.swift | 1 + resources/ios/NativeUITextRenderer.swift | 14 +++- resources/ios/NativeUITheme.swift | 43 ++++++++++- tests/FontTypographyTest.php | 77 +++++++++++++++++++ 31 files changed, 285 insertions(+), 38 deletions(-) create mode 100644 tests/FontTypographyTest.php 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 7c47b49..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 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/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/tests/FontTypographyTest.php b/tests/FontTypographyTest.php new file mode 100644 index 0000000..ff69a03 --- /dev/null +++ b/tests/FontTypographyTest.php @@ -0,0 +1,77 @@ +font()` → `font_name` (a resources/fonts/ file + * token the native renderers resolve; falls back to the theme's + * `font-family` default, then the system font). + * - `leading-*` classes → `line_height` (multiplier) / `line_height_px` + * (absolute), parsed by core's TailwindParser into lineHeight attrs. + * + * Core `text` element coverage lives in mobile-air (TextSelectionAndRunsTest); + * these pin the same contract on the native-ui button + text inputs. + */ + +function nuiProps(object $el): array +{ + return $el->toArray(new CallbackRegistry)['props']; +} + +// ── Button ────────────────────────────────────────────────────────────────── + +it('serializes the font attribute on a button', function () { + $button = Button::make('Save'); + $button->applyAttributes(['font' => 'Inter-Bold']); + + expect(nuiProps($button)['font_name'])->toBe('Inter-Bold'); +}); + +it('exposes a fluent font() on button', function () { + expect(nuiProps(Button::make('Go')->font('Lobster-Regular'))['font_name']) + ->toBe('Lobster-Regular'); +}); + +it('serializes line height attrs on a button', function () { + $button = Button::make('Save'); + $button->applyAttributes(['lineHeight' => 1.5, 'lineHeightPx' => 24]); + + $props = nuiProps($button); + expect($props['line_height'])->toBe(1.5); + expect($props['line_height_px'])->toBe(24.0); +}); + +// ── Text inputs (shared BaseTextInput) ────────────────────────────────────── + +it('serializes the font attribute on both input variants', function () { + foreach ([OutlinedTextInput::class, FilledTextInput::class] as $class) { + $input = $class::make(); + $input->applyAttributes(['font' => 'Inter-Regular']); + + expect(nuiProps($input)['font_name'])->toBe('Inter-Regular'); + } +}); + +it('exposes a fluent font() on inputs', function () { + expect(nuiProps(OutlinedTextInput::make()->font('Inter-Regular'))['font_name']) + ->toBe('Inter-Regular'); +}); + +it('serializes line height attrs on inputs', function () { + $input = OutlinedTextInput::make(); + $input->applyAttributes(['lineHeight' => 1.625]); + + expect(nuiProps($input)['line_height'])->toBe(1.625); +}); + +it('omits typography props when unset', function () { + $props = nuiProps(Button::make('Plain')); + + expect($props)->not->toHaveKey('font_name'); + expect($props)->not->toHaveKey('line_height'); + expect($props)->not->toHaveKey('line_height_px'); +}); From 64944c73d1b20b60c2c7cfc89fafef700727237b Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Jul 2026 21:49:25 -0400 Subject: [PATCH 8/8] =?UTF-8?q?feat(native-ui):=20native:font=20=E2=80=94?= =?UTF-8?q?=20download=20Google=20Fonts=20into=20resources/fonts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit php artisan native:font Lobster ("Rock Salt", multiple families, --weights=400,700, --italic) downloads TTFs straight from Google with no API key: the css2 endpoint serves truetype src urls to non-browser user agents, so we request the stylesheet, parse the @font-face blocks, and fetch the files. A 400 doubles as family-not-found. Files land as -