From ce5814177862e378ff60692dbc84dfb2018ec99b Mon Sep 17 00:00:00 2001 From: CodyPChristian Date: Fri, 28 Aug 2026 20:45:08 -0400 Subject: [PATCH] fix(ios): give a filled button a visible edge when its tint matches the surface A filled button whose tint resolves to the same colour as the surface it sits on has no visible edge -- it reads as text, not a control. This is easy to hit with a themed palette: a site whose brand colour is a pale cream and whose card surface is that same cream renders its primary action invisible. When primary or secondary collides with the surface, substitute a token that contrasts and keep the button filled. Filled matters: outlining it would preserve the brand colour but read as a secondary control, which is wrong for a screen's main action. The renderer cannot see what it is actually sitting on -- NodeView, which paints container backgrounds, is in the host package, so no ambient surface value reaches the button, and the same button may be used on a card or directly on the page. A candidate is therefore only accepted when it contrasts with both surface and background. Without that guard a palette whose secondary equals its page background goes invisible on the canvas while the card gets fixed. on-surface is tried next; it contrasts with the surface by construction. Semantic variants (destructive, success, accent) are never substituted -- a slate Delete button would not read as destructive -- and fall back to a hairline outline in their own label colour, as does any variant with no qualifying candidate. Detection is a channel-delta threshold tuned to catch 'same colour to the eye' without firing on light-on-light, so a normal palette is untouched. --- resources/ios/NativeUIButtonRenderer.swift | 105 ++++++++++++++++++++- 1 file changed, 101 insertions(+), 4 deletions(-) diff --git a/resources/ios/NativeUIButtonRenderer.swift b/resources/ios/NativeUIButtonRenderer.swift index 9d5ad41..36eabd0 100644 --- a/resources/ios/NativeUIButtonRenderer.swift +++ b/resources/ios/NativeUIButtonRenderer.swift @@ -256,10 +256,12 @@ struct NativeUIButtonRenderer: View { // 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. + let tint = resolveButtonTint(fill: theme.secondary, on: theme.onSecondary, theme: theme) Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.borderedProminent) - .tint(enabled ? theme.secondary : theme.surfaceVariant) - .foregroundStyle(enabled ? theme.onSecondary : theme.onSurfaceVariant) + .tint(enabled ? tint.fill : theme.surfaceVariant) + .foregroundStyle(enabled ? tint.foreground : theme.onSurfaceVariant) + .modifier(SurfaceCollisionOutline(active: tint.outline, stroke: tint.foreground, radius: 12)) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -269,10 +271,15 @@ struct NativeUIButtonRenderer: View { // Note: not using `role: .destructive` — it fights `.tint()` on // `.borderedProminent` and can render as the system destructive // color rather than the theme's destructive token. + // Semantic colour: never substituted, only outlined. A slate + // "Delete" would read as an ordinary action. Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.borderedProminent) .tint(enabled ? theme.destructive : theme.surfaceVariant) .foregroundStyle(enabled ? theme.onDestructive : theme.onSurfaceVariant) + .modifier(SurfaceCollisionOutline( + active: nativeUIColorsCollide(theme.destructive, theme.surface), + stroke: theme.onDestructive, radius: 12)) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -283,6 +290,9 @@ struct NativeUIButtonRenderer: View { .buttonStyle(.borderedProminent) .tint(enabled ? theme.success : theme.surfaceVariant) .foregroundStyle(enabled ? theme.onSuccess : theme.onSurfaceVariant) + .modifier(SurfaceCollisionOutline( + active: nativeUIColorsCollide(theme.success, theme.surface), + stroke: theme.onSuccess, radius: 12)) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -302,16 +312,21 @@ struct NativeUIButtonRenderer: View { .buttonStyle(.borderedProminent) .tint(enabled ? theme.accent : theme.surfaceVariant) .foregroundStyle(enabled ? theme.onAccent : theme.onSurfaceVariant) + .modifier(SurfaceCollisionOutline( + active: nativeUIColorsCollide(theme.accent, theme.surface), + stroke: theme.onAccent, radius: 12)) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) .modifier(A11yHintModifier(hint: a11yHint)) default: // "primary" and any unknown value + let tint = resolveButtonTint(fill: theme.primary, on: theme.onPrimary, theme: theme) Button(action: action) { content.fillWidthIfRequested(node) } .buttonStyle(.borderedProminent) - .tint(enabled ? theme.primary : theme.surfaceVariant) - .foregroundStyle(enabled ? theme.onPrimary : theme.onSurfaceVariant) + .tint(enabled ? tint.fill : theme.surfaceVariant) + .foregroundStyle(enabled ? tint.foreground : theme.onSurfaceVariant) + .modifier(SurfaceCollisionOutline(active: tint.outline, stroke: tint.foreground, radius: 12)) .controlSize(metrics.controlSize) .disabled(!enabled) .modifier(A11yLabelModifier(label: a11yLabel)) @@ -428,6 +443,88 @@ private struct A11yLoadingValueModifier: ViewModifier { } } +/// Sum of channel deltas. Below ~0.12 two colours are the same to the eye, +/// without firing on merely light-on-light. +private func nativeUIColorsCollide(_ a: Color, _ b: Color) -> Bool { + func components(_ c: Color) -> (CGFloat, CGFloat, CGFloat) { + var r: CGFloat = 0, g: CGFloat = 0, bl: CGFloat = 0, al: CGFloat = 0 + UIColor(c).getRed(&r, green: &g, blue: &bl, alpha: &al) + return (r, g, bl) + } + let (r1, g1, b1) = components(a) + let (r2, g2, b2) = components(b) + return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2) < 0.12 +} + +/// The fill and label a filled button should actually draw with. +private struct ButtonTint { + let fill: Color + let foreground: Color + let outline: Bool +} + +/// A filled button whose tint matches what it sits on has no visible edge — it +/// reads as text, not a control. That happens whenever a site's palette +/// resolves `primary` (or another variant token) to the same colour as +/// `surface`; Berkley Chapel's CMS returns #F4F1EC for both, so "Sign in" +/// vanished into its card. +/// +/// On a collision, substitute a token that contrasts and keep the button +/// filled — an outlined button reads as secondary, which is wrong for the +/// screen's main action. +/// +/// THE TWO-PARENT GUARD: the renderer cannot see what it is actually sitting +/// on. `NodeView` — which paints container backgrounds — lives in the host +/// package, so no ambient-surface value reaches here, and a button may be on a +/// card (`surface`) or directly on the page (`background`). A substitute is +/// therefore only accepted when it contrasts with BOTH. Without that guard +/// Berkley Chapel breaks: its `secondary` (#314E5E) contrasts beautifully with +/// the cream card and is exactly its page background, so every button on the +/// canvas would have gone slate-on-slate — worse than the collision being +/// fixed. `on-surface` then wins there: near-black reads on both. +/// +/// Falls back to outlining in the label colour if nothing qualifies, which +/// preserves the brand fill rather than guessing. Only engages on a near-match, +/// so a normal palette is untouched. +private func resolveButtonTint(fill: Color, on: Color, theme: NativeUITokens) -> ButtonTint { + guard nativeUIColorsCollide(fill, theme.surface) else { + return ButtonTint(fill: fill, foreground: on, outline: false) + } + + let candidates: [(Color, Color)] = [ + (theme.secondary, theme.onSecondary), + // Contrast-guaranteed against the surface by construction. + (theme.onSurface, theme.surface), + ] + + for (candidate, label) in candidates + where !nativeUIColorsCollide(candidate, theme.surface) + && !nativeUIColorsCollide(candidate, theme.background) + && !nativeUIColorsCollide(candidate, label) { + return ButtonTint(fill: candidate, foreground: label, outline: false) + } + + return ButtonTint(fill: fill, foreground: on, outline: true) +} + +/// Last-resort edge for a colliding button whose fill we chose not to replace. +private struct SurfaceCollisionOutline: ViewModifier { + let active: Bool + let stroke: Color + let radius: CGFloat + + func body(content: Content) -> some View { + if active { + content.overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(stroke.opacity(0.45), lineWidth: 1) + ) + } else { + content + } + } +} + private struct A11yLabelModifier: ViewModifier { let label: String func body(content: Content) -> some View {