mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
0155d95524
* feat(ios): adopt gateway user accent in chat The iOS chat surface previously always used the hardcoded brand accent. Read ui.prefs.accent ?? ui.seamColor (the Control UI user-accent contract) from the existing config.get branding refresh and feed it to the shared chat kit's userAccent seam, matching the macOS chat window. Invalid values fall through; accent resets on gateway switch. * fix(ios): contrast-aware accent ink and live config.changed refresh Address ClawSweeper review findings: the shared chat kit now derives user-text/send-glyph ink from the accent via the Control UI WCAG rule (relative luminance > 0.179 -> black), fixing unreadable light accents on iOS and macOS alike; the iOS server-event switch routes config.changed through the guarded branding refresh so an accent change reaches a connected app without reconnect.
248 lines
8.1 KiB
Swift
248 lines
8.1 KiB
Swift
import SwiftUI
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#else
|
|
import UIKit
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
extension NSAppearance {
|
|
fileprivate var isDarkAqua: Bool {
|
|
self.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
|
}
|
|
}
|
|
#endif
|
|
|
|
enum OpenClawChatTheme {
|
|
#if !os(macOS)
|
|
private enum IOSPalette {
|
|
static let lightCanvasTop = UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
|
|
static let lightCanvasMiddle = UIColor(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0, alpha: 1)
|
|
static let lightCanvasBottom = UIColor.white
|
|
static let lightAccent = UIColor(red: 220 / 255.0, green: 38 / 255.0, blue: 38 / 255.0, alpha: 1)
|
|
static let darkCanvasTop = UIColor(red: 12 / 255.0, green: 13 / 255.0, blue: 15 / 255.0, alpha: 1)
|
|
static let darkCanvasMiddle = UIColor(red: 7 / 255.0, green: 8 / 255.0, blue: 10 / 255.0, alpha: 1)
|
|
static let darkCanvasBottom = UIColor(red: 4 / 255.0, green: 5 / 255.0, blue: 6 / 255.0, alpha: 1)
|
|
static let darkPanel = UIColor(red: 10 / 255.0, green: 12 / 255.0, blue: 14 / 255.0, alpha: 1)
|
|
static let darkPanelRaised = UIColor(red: 17 / 255.0, green: 18 / 255.0, blue: 21 / 255.0, alpha: 1)
|
|
static let darkComposer = UIColor(red: 24 / 255.0, green: 25 / 255.0, blue: 28 / 255.0, alpha: 1)
|
|
static let darkAccent = UIColor(red: 198 / 255.0, green: 49 / 255.0, blue: 42 / 255.0, alpha: 1)
|
|
}
|
|
|
|
private static func adaptiveColor(
|
|
light: UIColor,
|
|
dark: UIColor) -> Color
|
|
{
|
|
Color(uiColor: UIColor { traits in
|
|
traits.userInterfaceStyle == .dark ? dark : light
|
|
})
|
|
}
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
static func resolvedAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
|
|
// NSColor semantic colors don't reliably resolve for arbitrary NSAppearance in SwiftPM.
|
|
// Use explicit light/dark values so the bubble updates when the system appearance flips.
|
|
appearance.isDarkAqua
|
|
? NSColor(calibratedWhite: 0.18, alpha: 0.88)
|
|
: NSColor(calibratedWhite: 0.94, alpha: 0.92)
|
|
}
|
|
|
|
static func resolvedOnboardingAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
|
|
appearance.isDarkAqua
|
|
? NSColor(calibratedWhite: 0.20, alpha: 0.94)
|
|
: NSColor(calibratedWhite: 0.97, alpha: 0.98)
|
|
}
|
|
|
|
static let assistantBubbleDynamicNSColor = NSColor(
|
|
name: NSColor.Name("OpenClawChatTheme.assistantBubble"),
|
|
dynamicProvider: resolvedAssistantBubbleColor(for:))
|
|
|
|
static let onboardingAssistantBubbleDynamicNSColor = NSColor(
|
|
name: NSColor.Name("OpenClawChatTheme.onboardingAssistantBubble"),
|
|
dynamicProvider: resolvedOnboardingAssistantBubbleColor(for:))
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
static var background: some View {
|
|
#if os(macOS)
|
|
// Plain material so the chat reads as a native surface; the window
|
|
// (or the anchored panel's effect view) supplies the vibrancy.
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
#else
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [
|
|
self.adaptiveColor(
|
|
light: IOSPalette.lightCanvasTop,
|
|
dark: IOSPalette.darkCanvasTop),
|
|
self.adaptiveColor(
|
|
light: IOSPalette.lightCanvasMiddle,
|
|
dark: IOSPalette.darkCanvasMiddle),
|
|
self.adaptiveColor(
|
|
light: IOSPalette.lightCanvasBottom,
|
|
dark: IOSPalette.darkCanvasBottom),
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static var subtleCard: AnyShapeStyle {
|
|
#if os(macOS)
|
|
AnyShapeStyle(.ultraThinMaterial)
|
|
#else
|
|
AnyShapeStyle(self.adaptiveColor(light: .tertiarySystemBackground, dark: IOSPalette.darkPanelRaised))
|
|
#endif
|
|
}
|
|
|
|
static var userBubble: Color {
|
|
#if os(macOS)
|
|
// Follow the user's system accent; hosts can still override per-view
|
|
// with `userAccent` (e.g. the seam color in the desktop app).
|
|
Color(nsColor: .controlAccentColor)
|
|
#else
|
|
self.adaptiveColor(
|
|
light: IOSPalette.lightAccent,
|
|
dark: IOSPalette.darkAccent)
|
|
#endif
|
|
}
|
|
|
|
static var accent: Color {
|
|
self.userBubble
|
|
}
|
|
|
|
static var danger: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .systemRed)
|
|
#else
|
|
Color(uiColor: .systemRed)
|
|
#endif
|
|
}
|
|
|
|
static var muted: Color {
|
|
.secondary
|
|
}
|
|
|
|
static var warning: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .systemOrange)
|
|
#else
|
|
Color(uiColor: .systemOrange)
|
|
#endif
|
|
}
|
|
|
|
static var success: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .systemGreen)
|
|
#else
|
|
Color(uiColor: .systemGreen)
|
|
#endif
|
|
}
|
|
|
|
static var assistantBubble: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: self.assistantBubbleDynamicNSColor)
|
|
#else
|
|
// iMessage-style grey receiver bubble: clearly visible on the white chat surface.
|
|
self.adaptiveColor(light: .systemGray5, dark: IOSPalette.darkPanelRaised)
|
|
#endif
|
|
}
|
|
|
|
static var onboardingAssistantBubble: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: self.onboardingAssistantBubbleDynamicNSColor)
|
|
#else
|
|
self.adaptiveColor(light: .secondarySystemBackground, dark: IOSPalette.darkPanelRaised)
|
|
#endif
|
|
}
|
|
|
|
static var onboardingAssistantBorder: Color {
|
|
#if os(macOS)
|
|
Color.white.opacity(0.12)
|
|
#else
|
|
Color.white.opacity(0.12)
|
|
#endif
|
|
}
|
|
|
|
static var userText: Color {
|
|
.white
|
|
}
|
|
|
|
/// Readable ink for text rendered on a host-supplied user accent. Mirrors the
|
|
/// Control UI accent contract (ui/src/app/control-ui-presentation.ts): WCAG
|
|
/// relative luminance, black/white reach equal contrast at 0.179. Without it,
|
|
/// light accents like #fbbf24 render unreadable fixed-white user text.
|
|
static func userText(on accent: Color?) -> Color {
|
|
guard let accent else { return self.userText }
|
|
return self.relativeLuminance(of: accent) > 0.179 ? .black : .white
|
|
}
|
|
|
|
static func relativeLuminance(of color: Color) -> Double {
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
#if os(macOS)
|
|
guard let rgb = NSColor(color).usingColorSpace(.sRGB) else { return 0 }
|
|
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
|
#else
|
|
guard UIColor(color).getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return 0 }
|
|
#endif
|
|
func linear(_ channel: CGFloat) -> Double {
|
|
let c = Double(channel)
|
|
return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
|
|
}
|
|
return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
|
|
}
|
|
|
|
static var assistantText: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .labelColor)
|
|
#else
|
|
Color(uiColor: .label)
|
|
#endif
|
|
}
|
|
|
|
static var composerBackground: AnyShapeStyle {
|
|
#if os(macOS)
|
|
AnyShapeStyle(.ultraThinMaterial)
|
|
#else
|
|
AnyShapeStyle(self.adaptiveColor(light: .secondarySystemGroupedBackground, dark: IOSPalette.darkPanel))
|
|
#endif
|
|
}
|
|
|
|
static var composerField: AnyShapeStyle {
|
|
#if os(macOS)
|
|
AnyShapeStyle(.thinMaterial)
|
|
#else
|
|
AnyShapeStyle(self.adaptiveColor(light: .secondarySystemBackground, dark: IOSPalette.darkComposer))
|
|
#endif
|
|
}
|
|
|
|
static var composerBorder: Color {
|
|
#if os(macOS)
|
|
Color.white.opacity(0.12)
|
|
#else
|
|
self.adaptiveColor(light: .separator, dark: UIColor.white.withAlphaComponent(0.14))
|
|
#endif
|
|
}
|
|
|
|
static var divider: Color {
|
|
Color.secondary.opacity(0.2)
|
|
}
|
|
}
|
|
|
|
enum OpenClawPlatformImageFactory {
|
|
static func image(_ image: OpenClawPlatformImage) -> Image {
|
|
#if os(macOS)
|
|
Image(nsImage: image)
|
|
#else
|
|
Image(uiImage: image)
|
|
#endif
|
|
}
|
|
}
|