fix(ios): improve light and dark appearance contrast (#98443)

This commit is contained in:
Peter Steinberger
2026-07-01 07:31:42 +01:00
committed by GitHub
parent 6495358f17
commit b2355ef6a2
5 changed files with 109 additions and 33 deletions
+33 -28
View File
@@ -29,6 +29,14 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
}
var detail: String {
switch self {
case .system: "Matches the system appearance."
case .light: "Always uses light appearance."
case .dark: "Always uses dark appearance."
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: nil
@@ -47,36 +55,19 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let uiAccent = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
}
static let uiAccent = adaptiveUIColor(light: (183, 56, 51), dark: (198, 62, 56))
static let uiOK = adaptiveUIColor(light: (19, 122, 62), dark: (48, 209, 88))
static let uiWarn = adaptiveUIColor(light: (154, 87, 0), dark: (255, 214, 10))
static let uiInfo = adaptiveUIColor(light: (0, 91, 196), dark: (100, 168, 255))
static let accent = Color(uiColor: Self.uiAccent)
static let accentHot = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
: UIColor(red: 204 / 255.0, green: 75 / 255.0, blue: 69 / 255.0, alpha: 1)
})
static let danger = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 252 / 255.0, green: 165 / 255.0, blue: 165 / 255.0, alpha: 1)
: UIColor(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0, alpha: 1)
})
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
static let graphite = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
: UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
})
static let graphiteElevated = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
: UIColor.white
})
static let accentHot = Color(uiColor: adaptiveUIColor(light: (204, 75, 69), dark: (232, 92, 86)))
static let danger = Color(uiColor: adaptiveUIColor(light: (185, 28, 28), dark: (252, 165, 165)))
static let ok = Color(uiColor: Self.uiOK)
static let warn = Color(uiColor: Self.uiWarn)
static let info = Color(uiColor: Self.uiInfo)
static let graphite = Color(uiColor: adaptiveUIColor(light: (246, 247, 249), dark: (20, 22, 24)))
static let graphiteElevated = Color(uiColor: adaptiveUIColor(light: (255, 255, 255), dark: (34, 36, 39)))
static var sheetBackground: LinearGradient {
LinearGradient(
@@ -88,6 +79,20 @@ enum OpenClawBrand {
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
private static func adaptiveUIColor(
light: (red: CGFloat, green: CGFloat, blue: CGFloat),
dark: (red: CGFloat, green: CGFloat, blue: CGFloat)) -> UIColor
{
UIColor { traits in
let components = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: components.red / 255,
green: components.green / 255,
blue: components.blue / 255,
alpha: 1)
}
}
}
extension View {
@@ -89,6 +89,10 @@ struct SettingsProTab: View {
self.settingsContent))
}
var appearancePreference: AppAppearancePreference {
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
}
@ViewBuilder
private var settingsContent: some View {
if let directRoute {
@@ -30,7 +30,7 @@ extension SettingsProTab {
}
}
.pickerStyle(.segmented)
Text("Follows iOS appearance.")
Text(self.appearancePreference.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
+54
View File
@@ -0,0 +1,54 @@
import Testing
import UIKit
@testable import OpenClaw
struct OpenClawBrandTests {
@Test func `appearance preference details match selection`() {
#expect(AppAppearancePreference.system.detail == "Matches the system appearance.")
#expect(AppAppearancePreference.light.detail == "Always uses light appearance.")
#expect(AppAppearancePreference.dark.detail == "Always uses dark appearance.")
}
@Test func `semantic colors meet text contrast in both appearances`() {
let colors = [OpenClawBrand.uiOK, OpenClawBrand.uiWarn, OpenClawBrand.uiInfo]
let backgrounds = [UIColor.systemBackground, UIColor.secondarySystemBackground]
for style in [UIUserInterfaceStyle.light, .dark] {
let traits = UITraitCollection(userInterfaceStyle: style)
for color in colors {
for background in backgrounds {
#expect(Self.contrastRatio(color, background, traits: traits) >= 4.5)
}
}
}
}
private static func contrastRatio(
_ foreground: UIColor,
_ background: UIColor,
traits: UITraitCollection) -> CGFloat
{
let foregroundLuminance = Self.relativeLuminance(foreground, traits: traits)
let backgroundLuminance = Self.relativeLuminance(background, traits: traits)
let lighter = max(foregroundLuminance, backgroundLuminance)
let darker = min(foregroundLuminance, backgroundLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
private static func relativeLuminance(_ color: UIColor, traits: UITraitCollection) -> CGFloat {
let resolved = color.resolvedColor(with: traits)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard resolved.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return 0 }
func linearize(_ component: CGFloat) -> CGFloat {
component <= 0.04045
? component / 12.92
: pow((component + 0.055) / 1.055, 2.4)
}
return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue)
}
}
+17 -4
View File
@@ -3,6 +3,11 @@ import XCTest
@MainActor
final class OpenClawSnapshotUITests: XCTestCase {
private enum ScreenshotAppearance: String, CaseIterable {
case light
case dark
}
private struct ScreenshotTarget {
let initialTab: String
let initialDestination: String
@@ -31,9 +36,12 @@ final class OpenClawSnapshotUITests: XCTestCase {
}
func testConnectedGatewayTabs() {
for target in Self.screenshotTargets {
launchApp(for: target)
snapshot(target.name, timeWaitingForIdle: 5)
for appearance in ScreenshotAppearance.allCases {
for target in Self.screenshotTargets {
launchApp(for: target, appearance: appearance)
let name = appearance == .light ? target.name : "\(target.name)-dark"
snapshot(name, timeWaitingForIdle: 5)
}
}
}
@@ -103,13 +111,18 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertEqual(app.state, .runningForeground)
}
private func launchApp(for target: ScreenshotTarget) {
private func launchApp(
for target: ScreenshotTarget,
appearance: ScreenshotAppearance = .light)
{
self.app?.terminate()
let app = XCUIApplication()
setupSnapshot(app, waitForAnimations: false)
app.launchArguments += [
"--openclaw-screenshot-mode",
"--openclaw-appearance",
appearance.rawValue,
"--openclaw-initial-tab",
target.initialTab,
"--openclaw-initial-destination",