Skip to content

Commit b2355ef

Browse files
authored
fix(ios): improve light and dark appearance contrast (#98443)
1 parent 6495358 commit b2355ef

5 files changed

Lines changed: 109 additions & 33 deletions

File tree

apps/ios/Sources/Design/OpenClawBrand.swift

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
2929
}
3030
}
3131

32+
var detail: String {
33+
switch self {
34+
case .system: "Matches the system appearance."
35+
case .light: "Always uses light appearance."
36+
case .dark: "Always uses dark appearance."
37+
}
38+
}
39+
3240
var colorScheme: ColorScheme? {
3341
switch self {
3442
case .system: nil
@@ -47,36 +55,19 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
4755
}
4856

4957
enum OpenClawBrand {
50-
static let uiAccent = UIColor { traits in
51-
traits.userInterfaceStyle == .dark
52-
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
53-
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
54-
}
58+
static let uiAccent = adaptiveUIColor(light: (183, 56, 51), dark: (198, 62, 56))
59+
static let uiOK = adaptiveUIColor(light: (19, 122, 62), dark: (48, 209, 88))
60+
static let uiWarn = adaptiveUIColor(light: (154, 87, 0), dark: (255, 214, 10))
61+
static let uiInfo = adaptiveUIColor(light: (0, 91, 196), dark: (100, 168, 255))
5562

5663
static let accent = Color(uiColor: Self.uiAccent)
57-
static let accentHot = Color(uiColor: UIColor { traits in
58-
traits.userInterfaceStyle == .dark
59-
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
60-
: UIColor(red: 204 / 255.0, green: 75 / 255.0, blue: 69 / 255.0, alpha: 1)
61-
})
62-
static let danger = Color(uiColor: UIColor { traits in
63-
traits.userInterfaceStyle == .dark
64-
? UIColor(red: 252 / 255.0, green: 165 / 255.0, blue: 165 / 255.0, alpha: 1)
65-
: UIColor(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0, alpha: 1)
66-
})
67-
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
68-
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
69-
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
70-
static let graphite = Color(uiColor: UIColor { traits in
71-
traits.userInterfaceStyle == .dark
72-
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
73-
: UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
74-
})
75-
static let graphiteElevated = Color(uiColor: UIColor { traits in
76-
traits.userInterfaceStyle == .dark
77-
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
78-
: UIColor.white
79-
})
64+
static let accentHot = Color(uiColor: adaptiveUIColor(light: (204, 75, 69), dark: (232, 92, 86)))
65+
static let danger = Color(uiColor: adaptiveUIColor(light: (185, 28, 28), dark: (252, 165, 165)))
66+
static let ok = Color(uiColor: Self.uiOK)
67+
static let warn = Color(uiColor: Self.uiWarn)
68+
static let info = Color(uiColor: Self.uiInfo)
69+
static let graphite = Color(uiColor: adaptiveUIColor(light: (246, 247, 249), dark: (20, 22, 24)))
70+
static let graphiteElevated = Color(uiColor: adaptiveUIColor(light: (255, 255, 255), dark: (34, 36, 39)))
8071

8172
static var sheetBackground: LinearGradient {
8273
LinearGradient(
@@ -88,6 +79,20 @@ enum OpenClawBrand {
8879
startPoint: .topLeading,
8980
endPoint: .bottomTrailing)
9081
}
82+
83+
private static func adaptiveUIColor(
84+
light: (red: CGFloat, green: CGFloat, blue: CGFloat),
85+
dark: (red: CGFloat, green: CGFloat, blue: CGFloat)) -> UIColor
86+
{
87+
UIColor { traits in
88+
let components = traits.userInterfaceStyle == .dark ? dark : light
89+
return UIColor(
90+
red: components.red / 255,
91+
green: components.green / 255,
92+
blue: components.blue / 255,
93+
alpha: 1)
94+
}
95+
}
9196
}
9297

9398
extension View {

apps/ios/Sources/Design/SettingsProTab.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ struct SettingsProTab: View {
8989
self.settingsContent))
9090
}
9191

92+
var appearancePreference: AppAppearancePreference {
93+
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
94+
}
95+
9296
@ViewBuilder
9397
private var settingsContent: some View {
9498
if let directRoute {

apps/ios/Sources/Design/SettingsProTabSections.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ extension SettingsProTab {
3030
}
3131
}
3232
.pickerStyle(.segmented)
33-
Text("Follows iOS appearance.")
33+
Text(self.appearancePreference.detail)
3434
.font(.caption)
3535
.foregroundStyle(.secondary)
3636
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Testing
2+
import UIKit
3+
@testable import OpenClaw
4+
5+
struct OpenClawBrandTests {
6+
@Test func `appearance preference details match selection`() {
7+
#expect(AppAppearancePreference.system.detail == "Matches the system appearance.")
8+
#expect(AppAppearancePreference.light.detail == "Always uses light appearance.")
9+
#expect(AppAppearancePreference.dark.detail == "Always uses dark appearance.")
10+
}
11+
12+
@Test func `semantic colors meet text contrast in both appearances`() {
13+
let colors = [OpenClawBrand.uiOK, OpenClawBrand.uiWarn, OpenClawBrand.uiInfo]
14+
let backgrounds = [UIColor.systemBackground, UIColor.secondarySystemBackground]
15+
16+
for style in [UIUserInterfaceStyle.light, .dark] {
17+
let traits = UITraitCollection(userInterfaceStyle: style)
18+
for color in colors {
19+
for background in backgrounds {
20+
#expect(Self.contrastRatio(color, background, traits: traits) >= 4.5)
21+
}
22+
}
23+
}
24+
}
25+
26+
private static func contrastRatio(
27+
_ foreground: UIColor,
28+
_ background: UIColor,
29+
traits: UITraitCollection) -> CGFloat
30+
{
31+
let foregroundLuminance = Self.relativeLuminance(foreground, traits: traits)
32+
let backgroundLuminance = Self.relativeLuminance(background, traits: traits)
33+
let lighter = max(foregroundLuminance, backgroundLuminance)
34+
let darker = min(foregroundLuminance, backgroundLuminance)
35+
return (lighter + 0.05) / (darker + 0.05)
36+
}
37+
38+
private static func relativeLuminance(_ color: UIColor, traits: UITraitCollection) -> CGFloat {
39+
let resolved = color.resolvedColor(with: traits)
40+
var red: CGFloat = 0
41+
var green: CGFloat = 0
42+
var blue: CGFloat = 0
43+
var alpha: CGFloat = 0
44+
guard resolved.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return 0 }
45+
46+
func linearize(_ component: CGFloat) -> CGFloat {
47+
component <= 0.04045
48+
? component / 12.92
49+
: pow((component + 0.055) / 1.055, 2.4)
50+
}
51+
52+
return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue)
53+
}
54+
}

apps/ios/UITests/OpenClawSnapshotUITests.swift

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import XCTest
33

44
@MainActor
55
final class OpenClawSnapshotUITests: XCTestCase {
6+
private enum ScreenshotAppearance: String, CaseIterable {
7+
case light
8+
case dark
9+
}
10+
611
private struct ScreenshotTarget {
712
let initialTab: String
813
let initialDestination: String
@@ -31,9 +36,12 @@ final class OpenClawSnapshotUITests: XCTestCase {
3136
}
3237

3338
func testConnectedGatewayTabs() {
34-
for target in Self.screenshotTargets {
35-
launchApp(for: target)
36-
snapshot(target.name, timeWaitingForIdle: 5)
39+
for appearance in ScreenshotAppearance.allCases {
40+
for target in Self.screenshotTargets {
41+
launchApp(for: target, appearance: appearance)
42+
let name = appearance == .light ? target.name : "\(target.name)-dark"
43+
snapshot(name, timeWaitingForIdle: 5)
44+
}
3745
}
3846
}
3947

@@ -103,13 +111,18 @@ final class OpenClawSnapshotUITests: XCTestCase {
103111
XCTAssertEqual(app.state, .runningForeground)
104112
}
105113

106-
private func launchApp(for target: ScreenshotTarget) {
114+
private func launchApp(
115+
for target: ScreenshotTarget,
116+
appearance: ScreenshotAppearance = .light)
117+
{
107118
self.app?.terminate()
108119

109120
let app = XCUIApplication()
110121
setupSnapshot(app, waitForAnimations: false)
111122
app.launchArguments += [
112123
"--openclaw-screenshot-mode",
124+
"--openclaw-appearance",
125+
appearance.rawValue,
113126
"--openclaw-initial-tab",
114127
target.initialTab,
115128
"--openclaw-initial-destination",

0 commit comments

Comments
 (0)