diff --git a/apps/ios/Sources/Design/OpenClawBrand.swift b/apps/ios/Sources/Design/OpenClawBrand.swift index d302b2928ea4..f06f3c6ebbe4 100644 --- a/apps/ios/Sources/Design/OpenClawBrand.swift +++ b/apps/ios/Sources/Design/OpenClawBrand.swift @@ -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 { diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index c9b262cea1a8..17c42a8de035 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -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 { diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 0a9241c17c62..96b86b8a50b4 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -30,7 +30,7 @@ extension SettingsProTab { } } .pickerStyle(.segmented) - Text("Follows iOS appearance.") + Text(self.appearancePreference.detail) .font(.caption) .foregroundStyle(.secondary) } diff --git a/apps/ios/Tests/OpenClawBrandTests.swift b/apps/ios/Tests/OpenClawBrandTests.swift new file mode 100644 index 000000000000..b282caa69b3b --- /dev/null +++ b/apps/ios/Tests/OpenClawBrandTests.swift @@ -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) + } +} diff --git a/apps/ios/UITests/OpenClawSnapshotUITests.swift b/apps/ios/UITests/OpenClawSnapshotUITests.swift index c71ecb6a8c14..e6cac10afca7 100644 --- a/apps/ios/UITests/OpenClawSnapshotUITests.swift +++ b/apps/ios/UITests/OpenClawSnapshotUITests.swift @@ -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",