From 8a9e5aaef60bb90b3ea9fc00258399534c140ee6 Mon Sep 17 00:00:00 2001 From: CodyPChristian Date: Fri, 21 Aug 2026 21:13:23 -0400 Subject: [PATCH] Give the outlined text input a fill color of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NativeUIOutlinedTextInputRenderer` draws its box as a bare `.stroke` and paints nothing inside it. That is Material 3's outlined container — correct, and fine on a white page — but it means the field's background is literally whatever happens to be behind it, and no theme token can change that. Put the same form on a colored screen and the field stops reading as a field: a rounded outline floating on the page color, with the M3 grays for its icons and placeholder chosen against a light background they are no longer on. The workaround today is `surface` or `surface-variant`, and it isn't one. Those tokens are shared with cards, sheets and list rows, so recoloring the field recolors everything else with it. There is no token whose scope is "the box the user types into". Two new optional tokens, named for that role rather than for a variant: 'input-fill' => '#FFFFFF', // the box 'on-input' => '#0F172A', // everything drawn inside it `input-fill` is transparent when undeclared, so a theme that says nothing renders byte-for-byte what it rendered before. `on-input` is genuinely OPTIONAL rather than defaulted — nil, not a color — because there is no single default it could take: a field is deliberately two-tone today (typed text `on-surface`, icons and affixes `on-surface-variant`), and collapsing that pair onto one token would restyle every existing field. Nil means each call site keeps its own color; a declared value takes the lot, which is what you want the moment the fill is dark enough that the muted gray stops being a hierarchy and starts being unreadable. `label` and `supporting` stay on `on-surface-variant`. They sit outside the box, on the surface behind the field, and should keep taking their color from it. The stroke moves into an overlay on the filled shape. Same shape, same `cornerRadius` (hoisted to a local now that two paints need it), same frame, so the border lands exactly where it did. The fill takes `.allowsHitTesting(false)`: a stroke only hit-tests its own line, so the hollow middle of the box used to let taps through, and a decorative fill should not start absorbing them. Android gets the same pair. `OutlinedTextFieldDefaults.colors()` already resolves the container to Transparent, so naming the four container states explicitly changes nothing until `input-fill` is declared. Two open threads this deliberately does not cut across: - #46 (placeholder-color on bare-text-input) solves the neighboring problem from the other end — a per-instance color on the variant whose whole contract is per-instance colors. That is the right shape there and the wrong shape here: outlined and filled reject per-instance colors by design (Model 3), so their answer has to be a theme token. The two do not overlap; if #46 lands first, the placeholder inside an outlined field is still an open question, and `on-input` is the natural place to answer it. - #26 (collapse outlined + filled into one `text-input`) is the reason these are `input-*` and not `outlined-*`. If the variants merge, the token still describes the field container and only one question is left open: whether `filled` should switch from `surface-variant` to `input-fill` too. It is untouched here — it already has a fill, and changing which token feeds it is a visible change to every filled field, which belongs in that issue and not in this PR. --- README.md | 7 +++ config/native-ui.php | 16 +++++ resources/android/NativeUITheme.kt | 36 +++++++++++ .../android/OutlinedTextInputRenderer.kt | 54 +++++++++++----- resources/boost/guidelines/core.blade.php | 5 ++ .../NativeUIOutlinedTextInputRenderer.swift | 62 ++++++++++++++----- resources/ios/NativeUITheme.swift | 36 +++++++++++ tests/ThemeColorTest.php | 27 ++++++++ 8 files changed, 213 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a8c5a0d..aebb85b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ 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. +`outlined-text-input` draws a transparent box by default (Material 3's +outlined container), so on a colored screen the field reads as part of the +page rather than as a field. Declare the optional `input-fill` / `on-input` +pair to give it a body of its own — `input-fill` paints the box, `on-input` +recolors everything inside it (typed text, placeholder, icons, +prefix/suffix). Leave them unset and nothing changes. + Icons accept platform enum overrides in Blade, matching the fluent API: ```blade diff --git a/config/native-ui.php b/config/native-ui.php index 42c808c..7298e4f 100644 --- a/config/native-ui.php +++ b/config/native-ui.php @@ -67,6 +67,22 @@ 'surface-variant' => '#F1F5F9', 'on-surface-variant' => '#475569', + // Text-field container, for the OUTLINED variant. Both are unset + // on purpose, and unset means "transparent box, Material 3 + // defaults inside" — the outlined field then reads as whatever is + // behind it, which is correct on a plain page and invisible on a + // colored one. Declare the pair to give the field a body of its + // own: + // + // 'input-fill' => '#FFFFFF', + // 'on-input' => '#0F172A', + // + // `on-input` recolors everything drawn INSIDE the box — typed + // text, placeholder, icons, prefix/suffix — so declare it + // alongside any fill dark enough to swallow the default grays. + // The label and supporting text sit outside the box and keep + // taking their color from the surface behind it. + // Outline = neutral borders (text fields, dividers, cards). // outline-variant = softer edges: hairline dividers, card seams. 'outline' => '#CBD5E1', diff --git a/resources/android/NativeUITheme.kt b/resources/android/NativeUITheme.kt index 95c7d69..ea33f69 100644 --- a/resources/android/NativeUITheme.kt +++ b/resources/android/NativeUITheme.kt @@ -43,6 +43,25 @@ data class NativeUITokens( val accent: Color, val onAccent: Color, + // Text-field container. Named for the ROLE, not for a variant: this is + // the box the user types into, wherever it is drawn. + // + // [inputFill] is transparent unless the app declares `input-fill`, which + // is both Material 3's outlined container and what + // `OutlinedTextFieldDefaults.colors()` already resolved to — so an app + // that says nothing sees nothing change. + // + // [onInput] is NULL when undeclared rather than resolved to a default, + // because there is no single default to resolve to: content inside a text + // field is deliberately two-tone today (typed text `onSurface`, icons, + // labels and placeholders `onSurfaceVariant`), and collapsing that pair + // onto one token would restyle every existing field. Null therefore means + // "keep each slot's existing color"; a declared value overrides the lot, + // which is what you want the moment `input-fill` is dark enough that the + // M3 grays stop being legible on it. + val inputFill: Color, + val onInput: Color?, + // Radii val radiusSm: Dp, val radiusMd: Dp, @@ -81,6 +100,8 @@ data class NativeUITokens( onSuccess = parseHex("#FFFFFF"), accent = parseHex("#FB923C"), onAccent = parseHex("#FFFFFF"), + inputFill = Color.Transparent, + onInput = null, radiusSm = 4.dp, radiusMd = 8.dp, radiusLg = 16.dp, radiusFull = 9999.dp, fontSm = 14.sp, fontMd = 16.sp, fontLg = 20.sp, fontXl = 24.sp, fontFamily = "System", @@ -173,6 +194,13 @@ object NativeUITheme { onSuccess = color(map["on-success"], fb.onSuccess), accent = color(map["accent"], fb.accent), onAccent = color(map["on-accent"], fb.onAccent), + inputFill = color(map["input-fill"], fb.inputFill), + // Optional on purpose — see the token declaration. `color()` can't + // express "absent", so this one keeps the fallback's own null-ness + // instead of substituting a color for it. The dark block's fallback + // is the resolved LIGHT token set, so declaring `on-input` under + // `light` alone still covers both. + onInput = optionalColor(map["on-input"], fb.onInput), radiusSm = radiusSm, radiusMd = radiusMd, radiusLg = radiusLg, radiusFull = radiusFull, fontSm = fontSm, fontMd = fontMd, fontLg = fontLg, fontXl = fontXl, fontFamily = fontFamily, @@ -270,6 +298,14 @@ private fun asMap(any: Any?): Map = when (any) { private fun color(any: Any?, fallback: Color): Color = (any as? String)?.takeIf { it.startsWith("#") }?.let(::parseHex) ?: fallback +/** + * [color] for tokens whose absence is meaningful. An undeclared token yields + * the fallback — including when the fallback is itself null — so "nobody has + * declared this" survives the light → dark inheritance chain intact. + */ +private fun optionalColor(any: Any?, fallback: Color?): Color? = + (any as? String)?.takeIf { it.startsWith("#") }?.let(::parseHex) ?: fallback + private fun numDp(any: Any?, fallback: Dp): Dp = when (any) { is Int -> any.dp is Long -> any.toInt().dp diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 4013484..a3d25fc 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -35,6 +35,11 @@ import com.nativephp.plugins.native_ui.NativeUITheme * * All colors drawn from [NativeUITheme] — per-instance color overrides are * intentionally not honored (plan doc Model 3). + * + * The container is filled with the `input-fill` theme token and its contents + * take `on-input`. Both are transparent / absent by default, which is what + * `OutlinedTextFieldDefaults.colors()` already resolved to, so an app that + * declares neither renders exactly as before. */ object OutlinedTextInputRenderer { @OptIn(ExperimentalMaterial3Api::class) @@ -110,6 +115,18 @@ object OutlinedTextInputRenderer { } } + // Everything INSIDE the box. Two tones by default — typed text at full + // emphasis, labels, placeholders and icons muted — which is the M3 + // hierarchy this renderer has always drawn. A declared `on-input` + // collapses both onto itself, because the moment `input-fill` is a + // saturated color the muted gray stops being a hierarchy and starts + // being unreadable. Supporting text is excluded: M3 draws it BELOW the + // box, on the surface behind the field, so it keeps that surface's + // colors. So is the focused label color, which is a focus accent + // (`primary`) rather than in-field content. + val fieldTextColor = theme.onInput ?: theme.onSurface + val fieldDecorationColor = theme.onInput ?: theme.onSurfaceVariant + val textSize = when (props.size) { "sm" -> theme.fontSm "lg" -> theme.fontLg @@ -148,7 +165,7 @@ object OutlinedTextInputRenderer { suffix = suffixSlot(props.suffix), leadingIcon = leadingIconSlot(props.leadingIcon), trailingIcon = if (props.loading) { - { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = theme.onSurfaceVariant) } + { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = fieldDecorationColor) } } else trailingIconSlot(props.trailingIcon), isError = props.isError, singleLine = props.singleLine, @@ -161,12 +178,21 @@ object OutlinedTextInputRenderer { selectionReporter.flush(value) dispatcher.onSubmit(value.text) }), - textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), + textStyle = TextStyle(fontSize = textSize, color = fieldTextColor, fontFamily = customFontFamily, lineHeight = lineHeight), colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = theme.onSurface, - unfocusedTextColor = theme.onSurface, - disabledTextColor = theme.onSurface.copy(alpha = 0.6f), - errorTextColor = theme.onSurface, + focusedTextColor = fieldTextColor, + unfocusedTextColor = fieldTextColor, + disabledTextColor = fieldTextColor.copy(alpha = 0.6f), + errorTextColor = fieldTextColor, + // The container defaults to Transparent in + // OutlinedTextFieldDefaults, so naming it here changes nothing + // until `input-fill` is declared. All four states take the + // same value: a field that vanishes into the page is just as + // wrong once it's focused or in error. + focusedContainerColor = theme.inputFill, + unfocusedContainerColor = theme.inputFill, + disabledContainerColor = theme.inputFill, + errorContainerColor = theme.inputFill, cursorColor = theme.primary, errorCursorColor = theme.destructive, focusedBorderColor = theme.primary, @@ -174,18 +200,18 @@ object OutlinedTextInputRenderer { disabledBorderColor = theme.outline.copy(alpha = 0.5f), errorBorderColor = theme.destructive, focusedLabelColor = theme.primary, - unfocusedLabelColor = theme.onSurfaceVariant, - disabledLabelColor = theme.onSurfaceVariant.copy(alpha = 0.6f), + unfocusedLabelColor = fieldDecorationColor, + disabledLabelColor = fieldDecorationColor.copy(alpha = 0.6f), errorLabelColor = theme.destructive, - focusedPlaceholderColor = theme.onSurfaceVariant, - unfocusedPlaceholderColor = theme.onSurfaceVariant, + focusedPlaceholderColor = fieldDecorationColor, + unfocusedPlaceholderColor = fieldDecorationColor, focusedSupportingTextColor = theme.onSurfaceVariant, unfocusedSupportingTextColor = theme.onSurfaceVariant, errorSupportingTextColor = theme.destructive, - focusedLeadingIconColor = theme.onSurfaceVariant, - unfocusedLeadingIconColor = theme.onSurfaceVariant, - focusedTrailingIconColor = theme.onSurfaceVariant, - unfocusedTrailingIconColor = theme.onSurfaceVariant, + focusedLeadingIconColor = fieldDecorationColor, + unfocusedLeadingIconColor = fieldDecorationColor, + focusedTrailingIconColor = fieldDecorationColor, + unfocusedTrailingIconColor = fieldDecorationColor, ), ) } diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index c929e29..ec2e643 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -89,6 +89,11 @@ - 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. +- `outlined-text-input` has NO fill by default — its box is transparent, so on + a colored screen it stops reading as a field. Declare the optional + `input-fill` / `on-input` token pair to give it one; `on-input` recolors + everything drawn inside the box. Do not reach for `surface` / `surface-variant` + for this, or every card moves with the field. - 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 — diff --git a/resources/ios/NativeUIOutlinedTextInputRenderer.swift b/resources/ios/NativeUIOutlinedTextInputRenderer.swift index 09729bb..fec03e2 100644 --- a/resources/ios/NativeUIOutlinedTextInputRenderer.swift +++ b/resources/ios/NativeUIOutlinedTextInputRenderer.swift @@ -13,6 +13,12 @@ import SwiftUI /// All chrome colors resolve from `NativeUITheme.shared`. Per-instance color /// overrides are intentionally not supported (Model 3 — drop to /// `` for fully custom input visuals). +/// +/// The box is filled with the `input-fill` theme token and its contents take +/// `on-input`. Both are transparent / absent by default, which is Material 3's +/// outlined container and reproduces this renderer exactly as it was before +/// the tokens existed; an app that wants its fields to read as fields on a +/// colored screen declares the pair and gets one. struct NativeUIOutlinedTextInputRenderer: View { let node: NativeUINode @@ -51,6 +57,28 @@ struct NativeUIOutlinedTextInputRenderer: View { let supportingColor: Color = isError ? theme.destructive : theme.onSurfaceVariant + // Everything INSIDE the box. Two tones by default — typed text at full + // emphasis, icons and affixes muted — which is the M3 hierarchy and + // what this renderer has always drawn. A declared `on-input` collapses + // both onto itself, because the moment `input-fill` is a saturated + // color the muted gray stops being a hierarchy and starts being + // unreadable. + // + // `label` and `supporting` sit OUTSIDE the box and are deliberately + // NOT included: they are painted on the surface behind the field, so + // they keep taking their color from it. + let fieldTextColor: Color = theme.onInput ?? theme.onSurface + let fieldDecorationColor: Color = theme.onInput ?? theme.onSurfaceVariant + + // Honor user-supplied border radius via class (e.g. `rounded-full` → + // 9999 → Capsule shape). Falls back to Material 3's outlined default + // (theme.radiusMd ≈ 4pt) when no class radius is set. Hoisted out of + // the background below now that the fill and the stroke both need it — + // one shape, two paints, so they cannot drift apart. + let cornerRadius: CGFloat = (node.style?.borderRadius ?? 0) > 0 + ? CGFloat(node.style!.borderRadius) + : theme.radiusMd + // The visible label doubles as the field's accessibility label unless // an explicit a11y_label override was provided. When the field is in // an error state, the supporting text must be announced: it rides the @@ -71,18 +99,18 @@ struct NativeUIOutlinedTextInputRenderer: View { if !leadingIcon.isEmpty { Image(systemName: getIconForName(leadingIcon)) .nuiScaledFont(size: metrics.iconSize) - .foregroundStyle(theme.onSurfaceVariant) + .foregroundStyle(fieldDecorationColor) } if !prefixText.isEmpty { Text(prefixText) .nuiScaledFont(size: metrics.textSize) - .foregroundStyle(theme.onSurfaceVariant) + .foregroundStyle(fieldDecorationColor) } NativeUITextInputCore( node: node, textSize: metrics.textSize, - contentColor: disabled ? theme.onSurface.opacity(0.6) : theme.onSurface, + contentColor: disabled ? fieldTextColor.opacity(0.6) : fieldTextColor, tintColor: isError ? theme.destructive : theme.primary ) .frame(maxWidth: .infinity, alignment: .leading) @@ -90,30 +118,32 @@ struct NativeUIOutlinedTextInputRenderer: View { if !suffixText.isEmpty { Text(suffixText) .nuiScaledFont(size: metrics.textSize) - .foregroundStyle(theme.onSurfaceVariant) + .foregroundStyle(fieldDecorationColor) } if loading { ProgressView().controlSize(.small) } else if !trailingIcon.isEmpty { Image(systemName: getIconForName(trailingIcon)) .nuiScaledFont(size: metrics.iconSize) - .foregroundStyle(theme.onSurfaceVariant) + .foregroundStyle(fieldDecorationColor) } } .padding(.horizontal, metrics.hPadding) .padding(.vertical, metrics.vPadding) .background( - // Honor user-supplied border radius via class (e.g. - // `rounded-full` → 9999 → Capsule shape). Falls back to - // Material 3's outlined default (theme.radiusMd ≈ 4pt) - // when no class radius is set. - RoundedRectangle( - cornerRadius: (node.style?.borderRadius ?? 0) > 0 - ? CGFloat(node.style!.borderRadius) - : theme.radiusMd, - style: .continuous - ) - .stroke(borderColor, lineWidth: isError ? 2 : 1) + // Fill first, stroke over it, both on the same shape so the + // border still sits exactly where it did — a stroke centers on + // its path, and the path here is the background's own frame, + // unchanged. The fill is decoration only: `.allowsHitTesting` + // keeps it from swallowing taps that used to fall through the + // hollow middle of the box. + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(theme.inputFill) + .allowsHitTesting(false) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(borderColor, lineWidth: isError ? 2 : 1) + ) ) .opacity(disabled ? 0.6 : 1.0) .allowsHitTesting(!disabled && !readOnly) diff --git a/resources/ios/NativeUITheme.swift b/resources/ios/NativeUITheme.swift index 98eae33..ae99f9b 100644 --- a/resources/ios/NativeUITheme.swift +++ b/resources/ios/NativeUITheme.swift @@ -29,6 +29,25 @@ struct NativeUITokens: Equatable { let accent: Color let onAccent: Color + // Text-field container. Named for the ROLE, not for a variant: this is + // the box the user types into, wherever it is drawn. + // + // `inputFill` is transparent unless the app declares `input-fill`, which + // is Material 3's outlined container and also exactly what the outlined + // renderer painted before the token existed — so an app that says nothing + // sees nothing change. + // + // `onInput` is NIL when undeclared rather than resolved to a default, + // because there is no single default to resolve to: content inside a text + // field is deliberately two-tone today (typed text `onSurface`, icons and + // affixes `onSurfaceVariant`), and collapsing that pair onto one token + // would restyle every existing field. Nil therefore means "keep each call + // site's existing color"; a declared value overrides the lot, which is + // what you want the moment `input-fill` is dark enough that the M3 grays + // stop being legible on it. + let inputFill: Color + let onInput: Color? + // Radii (points) let radiusSm: CGFloat let radiusMd: CGFloat @@ -63,6 +82,8 @@ struct NativeUITokens: Equatable { onSuccess: Color(hex: "#FFFFFF"), accent: Color(hex: "#C2410C"), onAccent: Color(hex: "#FFFFFF"), + inputFill: .clear, + onInput: nil, radiusSm: 4, radiusMd: 8, radiusLg: 16, radiusFull: 9999, fontSm: 14, fontMd: 16, fontLg: 20, fontXl: 24, fontFamily: "System" @@ -131,6 +152,13 @@ final class NativeUITheme: ObservableObject { onSuccess: hex(map["on-success"], fallback: fallbackTo.onSuccess), accent: hex(map["accent"], fallback: fallbackTo.accent), onAccent: hex(map["on-accent"], fallback: fallbackTo.onAccent), + inputFill: hex(map["input-fill"], fallback: fallbackTo.inputFill), + // Optional on purpose — see the token declaration. `hex()` + // can't express "absent", so this one keeps the fallback's own + // nil-ness instead of substituting a color for it. The dark + // block's fallback is the resolved LIGHT token set, so + // declaring `on-input` under `light` alone still covers both. + onInput: optionalHex(map["on-input"], fallback: fallbackTo.onInput), radiusSm: radiusSm, radiusMd: radiusMd, radiusLg: radiusLg, radiusFull: radiusFull, fontSm: fontSm, fontMd: fontMd, fontLg: fontLg, fontXl: fontXl, fontFamily: fontFamily @@ -199,6 +227,14 @@ private func hex(_ any: Any?, fallback: Color) -> Color { return Color(hex: s) } +/// `hex()` for tokens whose absence is meaningful. An undeclared token yields +/// the fallback — including when the fallback is itself nil — so "nobody has +/// declared this" survives the light → dark inheritance chain intact. +private func optionalHex(_ any: Any?, fallback: Color?) -> Color? { + guard let s = any as? String, s.hasPrefix("#") else { return fallback } + return Color(hex: s) +} + private func cgf(_ any: Any?, fallback: CGFloat) -> CGFloat { if let n = any as? CGFloat { return n } if let n = any as? Double { return CGFloat(n) } diff --git a/tests/ThemeColorTest.php b/tests/ThemeColorTest.php index 995d932..5332f3c 100644 --- a/tests/ThemeColorTest.php +++ b/tests/ThemeColorTest.php @@ -51,6 +51,33 @@ expect(Theme::get('light.accent'))->toBe('not-a-color'); }); + it('carries the optional input-fill / on-input pair through the color grammar', function () { + // The token map is open-ended, so these two need no special handling + // in Theme — but the outlined text input is the only element whose + // DEFAULT appearance depends on them being absent, so pin that they + // travel like any other color when they are present. + Theme::load(['light' => [ + 'input-fill' => 'slate-800', + 'on-input' => '#F8FAFC', + ]]); + + expect(Theme::get('light.input-fill'))->toBe('#1E293B'); + expect(Theme::get('light.on-input'))->toBe('#F8FAFC'); + }); + + it('omits input-fill / on-input entirely when they are not declared', function () { + // Absence is the signal the renderers read: no `input-fill` means a + // transparent box (Material 3's outlined container), and no `on-input` + // means each slot inside the field keeps its own default color. A + // synthesized default here would quietly restyle every existing field. + Theme::load(['light' => ['primary' => '#B91C1C']]); + + expect(Theme::all()['light'])->not->toHaveKey('input-fill'); + expect(Theme::all()['light'])->not->toHaveKey('on-input'); + expect(Theme::all()['dark'])->not->toHaveKey('input-fill'); + expect(Theme::all()['dark'])->not->toHaveKey('on-input'); + }); + it('normalizes tokens supplied via merge()', function () { Theme::load(['light' => ['primary' => '#B91C1C']]); Theme::merge(['light' => ['accent' => 'orange-800/50']]);