home / skills / kaakati / rails-enterprise-dev / theme-management

This skill helps you make expert theming decisions for iOS/tvOS, guiding when to use system colors versus custom themes and accessibility.

npx playbooks add skill kaakati/rails-enterprise-dev --skill theme-management

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
10.8 KB
---
name: theme-management
description: "Expert theming decisions for iOS/tvOS: when custom themes add value vs system colors suffice, color token architecture trade-offs, theme switching animation strategies, and accessibility contrast compliance. Use when designing color systems, implementing dark mode, or building theme pickers. Trigger keywords: theme, dark mode, light mode, color scheme, appearance, colorScheme, ThemeManager, adaptive colors, dynamic colors, color tokens, WCAG contrast"
version: "3.0.0"
---

# 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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

```swift
// 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

```swift
@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

```swift
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 |

Overview

This skill provides expert guidance for theming iOS and tvOS apps, helping you decide when to rely on system colors versus implementing custom themes. It covers color token architecture, theme switching strategies, accessibility contrast checks, and common pitfalls to avoid. Use it to make pragmatic, maintainable theming decisions that respect dark mode and user preferences.

How this skill works

The skill inspects your requirements (brand needs, user-selectable themes, white-label) and recommends an appropriate architecture: asset catalog colors, color extensions, semantic tokens, or a full ThemeManager. It evaluates trade-offs for theme persistence, animation strategies for switching, and contrast validation against WCAG rules. It also flags anti-patterns like hardcoded colors, scattered theme state, and unnecessary overrides of system preferences.

When to use it

  • Designing a color system for a new app or refactor
  • Deciding whether to implement a ThemeManager or use system colors
  • Implementing dark mode and ensuring accessibility contrast
  • Building a theme picker for users or multi-brand support
  • Choosing animation strategy for theme switches across screens

Best practices

  • Prefer system semantic colors for standard light/dark support; only add custom tokens when brand requirements demand them
  • Organize tokens: primitives (raw swatches) → semantic tokens → usage in views for scalability
  • Persist a single source of truth for theme state (one ThemeManager using AppStorage) and avoid storing theme in multiple places
  • Use asset catalog named colors for light/dark variants when possible to let Xcode handle appearance switching
  • Validate foreground/background pairs against WCAG (AA 4.5:1 for normal text) and favor elevated surfaces over pure black/white
  • Animate theme changes with environment propagation or withAnimation; reserve snapshots/crossfades for special effects

Example use cases

  • Small app: use a Color extension with a few named colors and rely on system backgrounds
  • Medium single-brand app: add static Color.appPrimary and semantic tokens for text and surfaces
  • Large design system: implement primitive swatches and semantic tokens mapped to components
  • User-selectable themes: implement ThemeManager with persisted selection and per-window override only when user chooses
  • White-label app: fetch remote brand palettes and instantiate protocol-based Theme implementations

FAQ

When is a ThemeManager overkill?

If your app only needs standard light/dark support, asset catalog named colors and system semantic colors are sufficient; a ThemeManager is unnecessary complexity.

How should I handle contrast testing?

Compute contrast ratios between foreground and relevant backgrounds and require at least WCAG AA (4.5:1) for normal text; prefer system semantic colors when possible since they are maintained for accessibility.