theme-management

SKILL.md

Theme Management — Expert Decisions

Expert decision frameworks for theming choices in SwiftUI. Claude knows Color and colorScheme — this skill provides judgment calls for when custom theming adds value and architecture trade-offs.


Decision Trees

Do You Need Custom Theming?

What's your color requirement?
├─ Standard light/dark mode only
│  └─ System colors are sufficient
│     Color(.systemBackground), Color(.label)
├─ Brand colors that differ from system
│  └─ Asset catalog colors (Named Colors)
│     Define light/dark variants in xcassets
├─ User-selectable themes (beyond light/dark)
│  └─ Full ThemeManager with custom palettes
│     User can choose "Ocean", "Forest", etc.
└─ White-label app (different branding per client)
   └─ Remote theme configuration
      Fetch brand colors from server

The trap: Building a ThemeManager for an app that only needs light/dark mode. Asset catalog colors already handle this automatically.

Color Token Architecture

How should you organize colors?
├─ Small app (< 20 screens)
│  └─ Direct Color("name") usage
│     Don't over-engineer
├─ Medium app, single brand
│  └─ Color extension with static properties
│     Color.appPrimary, Color.appBackground
├─ Large app, design system
│  └─ Semantic tokens + primitive tokens
│     Primitives: blue500, gray100
│     Semantic: textPrimary → gray900/gray100
└─ Multi-brand/white-label
   └─ Protocol-based themes
      protocol Theme { var primary: Color }

Theme Switching Strategy

When should theme changes apply?
├─ Immediate (all screens at once)
│  └─ Environment-based (@Environment(\.colorScheme))
│     System handles propagation
├─ Per-screen animation
│  └─ withAnimation on theme property change
│     Smooth transition within visible content
├─ Custom transition (like morphing)
│  └─ Snapshot + crossfade technique
│     Complex, usually not worth it
└─ App restart required
   └─ For deep UIKit integration
      Sometimes unavoidable with third-party SDKs

Dark Mode Compliance Level

What's your dark mode strategy?
├─ System automatic (no custom colors)
│  └─ Free dark mode support
│     UIColor semantic colors work automatically
├─ Custom colors with asset catalog
│  └─ Define both appearances per color
│     Xcode handles switching
├─ Programmatic dark variants
│  └─ Use Color.dynamic(light:dark:)
│     More flexible, harder to maintain
└─ Ignoring dark mode
   └─ DON'T — accessibility issue
      Users expect dark mode support

NEVER Do

Color Definition

NEVER hardcode colors in views:

// ❌ Doesn't adapt to dark mode
Text("Hello")
    .foregroundColor(Color(red: 0, green: 0, blue: 0))
    .background(Color.white)

// ✅ Adapts automatically
Text("Hello")
    .foregroundColor(Color(.label))
    .background(Color(.systemBackground))

// Or use named colors from asset catalog
Text("Hello")
    .foregroundColor(Color("TextPrimary"))

NEVER use opposite colors for dark mode:

// ❌ Pure black/white has harsh contrast
let background = colorScheme == .dark ? Color.black : Color.white
let text = colorScheme == .dark ? Color.white : Color.black

// ✅ Use elevated surfaces and softer contrasts
let background = Color(.systemBackground)  // Slightly elevated in dark mode
let text = Color(.label)  // Not pure white in dark mode

NEVER check colorScheme when system colors suffice:

// ❌ Unnecessary — system colors do this
@Environment(\.colorScheme) var colorScheme

var textColor: Color {
    colorScheme == .dark ? .white : .black  // Reimplementing Color(.label)!
}

// ✅ Just use the semantic color
.foregroundColor(Color(.label))

Theme Architecture

NEVER store theme in multiple places:

// ❌ State scattered — which is source of truth?
class ThemeManager {
    @Published var isDark = false
}

struct SettingsView: View {
    @AppStorage("isDark") var isDark = false  // Different storage!
}

// ✅ Single source of truth
class ThemeManager: ObservableObject {
    @AppStorage("theme") var theme: Theme = .system  // One place
}

NEVER force override system preference without user consent:

// ❌ Ignores user's system-wide preference
init() {
    UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .dark
}

// ✅ Only override if user explicitly chose in-app
func applyUserPreference(_ theme: Theme) {
    switch theme {
    case .system:
        window?.overrideUserInterfaceStyle = .unspecified  // Respect system
    case .light:
        window?.overrideUserInterfaceStyle = .light
    case .dark:
        window?.overrideUserInterfaceStyle = .dark
    }
}

NEVER forget accessibility contrast requirements:

// ❌ Low contrast — fails WCAG AA
let textColor = Color(white: 0.6)  // On white background = 2.5:1 ratio
let backgroundColor = Color.white

// ✅ Meets WCAG AA (4.5:1 for normal text)
let textColor = Color(.secondaryLabel)  // System ensures compliance
let backgroundColor = Color(.systemBackground)

Performance

NEVER recompute colors on every view update:

// ❌ Creates new color object every render
var body: some View {
    Text("Hello")
        .foregroundColor(Color(UIColor { traits in
            traits.userInterfaceStyle == .dark ? .white : .black
        }))  // New UIColor closure every time!
}

// ✅ Define once, reuse
extension Color {
    static let adaptiveText = Color(UIColor { traits in
        traits.userInterfaceStyle == .dark ? .white : .black
    })
}

var body: some View {
    Text("Hello")
        .foregroundColor(.adaptiveText)
}

Essential Patterns

Semantic Color System

// Primitive tokens (raw values)
extension Color {
    enum Primitive {
        static let blue500 = Color(hex: "#007AFF")
        static let blue600 = Color(hex: "#0056B3")
        static let gray900 = Color(hex: "#1A1A1A")
        static let gray100 = Color(hex: "#F5F5F5")
    }
}

// Semantic tokens (usage-based)
extension Color {
    static let textPrimary = Color("TextPrimary")  // gray900 light, gray100 dark
    static let textSecondary = Color("TextSecondary")
    static let surfacePrimary = Color("SurfacePrimary")
    static let surfaceSecondary = Color("SurfaceSecondary")
    static let interactive = Color("Interactive")  // blue500
    static let interactivePressed = Color("InteractivePressed")  // blue600

    // Feedback colors (always same hue, adjusted for mode)
    static let success = Color("Success")
    static let warning = Color("Warning")
    static let error = Color("Error")
}

ThemeManager with Persistence

@MainActor
final class ThemeManager: ObservableObject {
    static let shared = ThemeManager()

    @AppStorage("selectedTheme") private var storedTheme: String = Theme.system.rawValue

    @Published private(set) var currentTheme: Theme = .system

    enum Theme: String, CaseIterable {
        case system, light, dark

        var overrideStyle: UIUserInterfaceStyle {
            switch self {
            case .system: return .unspecified
            case .light: return .light
            case .dark: return .dark
            }
        }
    }

    private init() {
        currentTheme = Theme(rawValue: storedTheme) ?? .system
        applyTheme(currentTheme)
    }

    func setTheme(_ theme: Theme) {
        currentTheme = theme
        storedTheme = theme.rawValue
        applyTheme(theme)
    }

    private func applyTheme(_ theme: Theme) {
        // Apply to all windows (handles multiple scenes)
        for scene in UIApplication.shared.connectedScenes {
            guard let windowScene = scene as? UIWindowScene else { continue }
            for window in windowScene.windows {
                window.overrideUserInterfaceStyle = theme.overrideStyle
            }
        }
    }
}

Contrast Validation

extension Color {
    func contrastRatio(against background: Color) -> Double {
        let fgLuminance = relativeLuminance
        let bgLuminance = background.relativeLuminance

        let lighter = max(fgLuminance, bgLuminance)
        let darker = min(fgLuminance, bgLuminance)

        return (lighter + 0.05) / (darker + 0.05)
    }

    var meetsWCAGAA: Bool {
        // Check against both light and dark backgrounds
        let lightBg = Color.white
        let darkBg = Color.black
        return contrastRatio(against: lightBg) >= 4.5 ||
               contrastRatio(against: darkBg) >= 4.5
    }

    private var relativeLuminance: Double {
        guard let components = UIColor(self).cgColor.components,
              components.count >= 3 else { return 0 }

        func linearize(_ value: CGFloat) -> Double {
            let v = Double(value)
            return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
        }

        return 0.2126 * linearize(components[0]) +
               0.7152 * linearize(components[1]) +
               0.0722 * linearize(components[2])
    }
}

Quick Reference

When to Use Custom Theming

Scenario Solution
Standard light/dark System colors only
Brand colors Asset catalog named colors
User-selectable themes ThemeManager + color palettes
White-label Remote config + theme protocol

System vs Custom Colors

Need System Custom
Text colors Color(.label), Color(.secondaryLabel) Only if brand requires
Backgrounds Color(.systemBackground) Only if brand requires
Dividers Color(.separator) Rarely
Tint/accent Color.accentColor Usually brand color

Contrast Ratios (WCAG)

Level Normal Text Large Text Use Case
AA 4.5:1 3:1 Minimum acceptable
AAA 7:1 4.5:1 Enhanced readability

Red Flags

Smell Problem Fix
Color(red:green:blue:) in view No dark mode Use semantic colors
@Environment colorScheme everywhere Over-engineering System colors adapt automatically
Pure black/white Harsh contrast Elevated surfaces
Theme in multiple @AppStorage Split source of truth Single ThemeManager
window.overrideUserInterfaceStyle in init Ignores user pref Only on explicit user action
Weekly Installs
14
GitHub Stars
6
First Seen
Jan 25, 2026
Installed on
claude-code12
opencode12
gemini-cli11
codex11
antigravity10
github-copilot10