Bedrock/Sources/Bedrock/Theme/THEME_GUIDE.md

14 KiB

App Theme Guide

This guide explains how to create a custom color theme for your iOS app using the Bedrock theming system. The system uses protocols to define semantic color categories, making it easy to maintain consistent colors throughout your app and switch themes if needed.

Overview

The theming system consists of:

  1. Color Protocols — Define what colors your theme must provide
  2. Color Enums — Your app's concrete color implementations
  3. Theme Enum — Combines all color providers into one theme
  4. Typealiases — Provide clean, short names for use in views

Step 1: Create Your Theme File

Create a file called YourAppTheme.swift in your app's Shared/Theme/ folder.

import SwiftUI
import Bedrock

// MARK: - YourApp Surface Colors

/// Surface colors for backgrounds and containers.
public enum YourAppSurfaceColors: SurfaceColorProvider {
    /// Primary background - darkest/base color
    public static let primary = Color(red: 0.08, green: 0.06, blue: 0.10)
    
    /// Secondary/elevated surface - slightly lighter
    public static let secondary = Color(red: 0.12, green: 0.08, blue: 0.14)
    
    /// Tertiary/card surface - more elevated
    public static let tertiary = Color(red: 0.16, green: 0.11, blue: 0.18)
    
    /// Overlay background (for sheets/modals)
    public static let overlay = Color(red: 0.10, green: 0.07, blue: 0.12)
    
    /// Card/grouped element background
    public static let card = Color(red: 0.14, green: 0.10, blue: 0.16)
    
    /// Subtle fill for grouped content sections
    public static let groupedFill = Color(red: 0.12, green: 0.09, blue: 0.14)
    
    /// Section fill for list sections
    public static let sectionFill = Color(red: 0.16, green: 0.12, blue: 0.18)
}

// MARK: - YourApp Text Colors

/// Text colors for labels and content.
public enum YourAppTextColors: TextColorProvider {
    /// Primary text - highest emphasis (usually white for dark themes)
    public static let primary = Color.white
    
    /// Secondary text - muted
    public static let secondary = Color.white.opacity(Design.Opacity.accent)
    
    /// Tertiary text - hints and captions
    public static let tertiary = Color.white.opacity(Design.Opacity.medium)
    
    /// Disabled text
    public static let disabled = Color.white.opacity(Design.Opacity.light)
    
    /// Placeholder text
    public static let placeholder = Color.white.opacity(Design.Opacity.overlay)
    
    /// Inverse text (for contrasting backgrounds like buttons)
    public static let inverse = Color.black
}

// MARK: - YourApp Accent Colors

/// Accent colors for interactive elements and branding.
public enum YourAppAccentColors: AccentColorProvider {
    /// Primary accent - your main brand color
    public static let primary = Color(red: 0.85, green: 0.25, blue: 0.45)
    
    /// Light variant - softer version
    public static let light = Color(red: 0.95, green: 0.45, blue: 0.60)
    
    /// Dark variant - deeper version
    public static let dark = Color(red: 0.65, green: 0.18, blue: 0.35)
    
    /// Secondary accent - for contrast or secondary actions
    public static let secondary = Color(red: 1.0, green: 0.95, blue: 0.90)
}

// MARK: - YourApp Button Colors

/// Button-specific colors for different button states and types.
public enum YourAppButtonColors: ButtonColorProvider {
    /// Light gradient color for primary buttons
    public static let primaryLight = Color(red: 0.95, green: 0.40, blue: 0.55)
    
    /// Dark gradient color for primary buttons
    public static let primaryDark = Color(red: 0.75, green: 0.20, blue: 0.40)
    
    /// Secondary button background
    public static let secondary = Color.white.opacity(Design.Opacity.subtle)
    
    /// Destructive action color
    public static let destructive = Color.red.opacity(Design.Opacity.heavy)
    
    /// Cancel button text color
    public static let cancelText = Color.white.opacity(Design.Opacity.strong)
}

// MARK: - YourApp Status Colors

/// Semantic status colors for feedback.
public enum YourAppStatusColors: StatusColorProvider {
    /// Success/positive state
    public static let success = Color(red: 0.2, green: 0.8, blue: 0.4)
    
    /// Warning state
    public static let warning = Color(red: 1.0, green: 0.75, blue: 0.2)
    
    /// Error/negative state
    public static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
    
    /// Informational state
    public static let info = Color(red: 0.5, green: 0.7, blue: 0.95)
}

// MARK: - YourApp Border Colors

/// Border and divider colors.
public enum YourAppBorderColors: BorderColorProvider {
    /// Subtle border - barely visible
    public static let subtle = Color.white.opacity(Design.Opacity.subtle)
    
    /// Standard border
    public static let standard = Color.white.opacity(Design.Opacity.hint)
    
    /// Emphasized border - more visible
    public static let emphasized = Color.white.opacity(Design.Opacity.light)
    
    /// Selected/active border - uses accent color
    public static let selected = YourAppAccentColors.primary.opacity(Design.Opacity.medium)
}

// MARK: - YourApp Interactive Colors

/// Colors for interactive states (hover, pressed, selected).
public enum YourAppInteractiveColors: InteractiveColorProvider {
    /// Selected state
    public static let selected = YourAppAccentColors.primary.opacity(Design.Opacity.selection)
    
    /// Hover/highlight state
    public static let hover = Color.white.opacity(Design.Opacity.subtle)
    
    /// Pressed state
    public static let pressed = Color.white.opacity(Design.Opacity.hint)
    
    /// Focus ring color
    public static let focus = YourAppAccentColors.light
}

// MARK: - YourApp Theme

/// The complete theme combining all color providers.
public enum YourAppTheme: AppColorTheme {
    public typealias Surface = YourAppSurfaceColors
    public typealias Text = YourAppTextColors
    public typealias Accent = YourAppAccentColors
    public typealias Button = YourAppButtonColors
    public typealias Status = YourAppStatusColors
    public typealias Border = YourAppBorderColors
    public typealias Interactive = YourAppInteractiveColors
}

Step 2: Create Convenience Typealiases

Add short typealiases at the bottom of your theme file for cleaner usage in views:

// MARK: - Convenience Typealiases

/// Short typealiases for cleaner usage throughout the app.
///
/// Usage:
/// ```swift
/// .background(AppSurface.primary)
/// .foregroundStyle(AppAccent.primary)
/// .foregroundStyle(AppText.secondary)
/// ```
typealias AppSurface = YourAppSurfaceColors
typealias AppText = YourAppTextColors
typealias AppAccent = YourAppAccentColors
typealias AppButton = YourAppButtonColors
typealias AppStatus = YourAppStatusColors
typealias AppBorder = YourAppBorderColors
typealias AppInteractive = YourAppInteractiveColors

Step 3: Use Theme Colors in Views

Background Colors

struct ContentView: View {
    var body: some View {
        VStack {
            // Primary background
        }
        .background(AppSurface.primary)
    }
}

struct SettingsView: View {
    var body: some View {
        ScrollView {
            VStack {
                SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
                    // Card content
                }
            }
        }
        .background(AppSurface.primary)
    }
}

Text Colors

// Primary text (titles, important content)
Text("Settings")
    .foregroundStyle(AppText.primary)

// Secondary text (subtitles, descriptions)
Text("Adjust your preferences")
    .foregroundStyle(AppText.secondary)

// Tertiary text (hints, captions)
Text("Last updated 5 minutes ago")
    .foregroundStyle(AppText.tertiary)

// Disabled text
Text("Feature unavailable")
    .foregroundStyle(AppText.disabled)

Accent Colors

// Toggles and interactive elements
SettingsToggle(
    title: "Enable Feature",
    isOn: $isEnabled,
    accentColor: AppAccent.primary
)

// Buttons
Button("Save") { save() }
    .foregroundStyle(AppAccent.primary)

// Section headers
SettingsSectionHeader(
    title: "Display", 
    systemImage: "eye", 
    accentColor: AppAccent.primary
)

Status Colors

// Success indicator
Image(systemName: "checkmark.circle.fill")
    .foregroundStyle(AppStatus.success)

// Warning badge
Text("Premium")
    .foregroundStyle(AppStatus.warning)

// Error message
Text("Something went wrong")
    .foregroundStyle(AppStatus.error)

Border Colors

RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
    .strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)

// Selected state
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
    .strokeBorder(AppBorder.selected, lineWidth: Design.LineWidth.medium)

Color Categories Reference

Category Purpose Examples
Surface Backgrounds, containers Screen background, cards, modals
Text All text content Titles, labels, hints, placeholders
Accent Brand colors, interactive elements Toggles, buttons, links
Button Button-specific colors Primary buttons, destructive actions
Status Semantic feedback Success, warning, error, info
Border Borders and dividers Card borders, separators
Interactive State colors Selected, hover, pressed, focus

Design.Opacity Reference

The Design.Opacity enum provides consistent opacity values:

Name Value Use Case
verySubtle 0.03 Barely visible fills
subtle 0.08 Subtle backgrounds
hint 0.12 Hint borders
light 0.25 Light overlays
overlay 0.35 Overlay backgrounds
medium 0.50 Medium emphasis
accent 0.65 Accented elements
strong 0.75 Strong emphasis
heavy 0.85 Heavy emphasis
selection 0.15 Selection highlights

Tips

1. Use Semantic Names

Always use the semantic color names, not raw values:

// ✅ Good - semantic name
.foregroundStyle(AppText.secondary)

// ❌ Bad - raw opacity value
.foregroundStyle(.white.opacity(0.65))

2. Keep Colors Centralized

Never define colors inline. Add them to your theme file:

// ✅ Good - uses theme
.background(AppSurface.card)

// ❌ Bad - inline color
.background(Color(red: 0.14, green: 0.10, blue: 0.16))

3. Use Appropriate Text Hierarchy

  • AppText.primary — Titles, important content
  • AppText.secondary — Subtitles, descriptions
  • AppText.tertiary — Captions, hints, metadata
  • AppText.disabled — Unavailable content
  • AppText.placeholder — Input placeholders

4. Consistent Accent Usage

Use the accent color for:

  • Toggle switches
  • Buttons (primary actions)
  • Links
  • Selected states
  • Active indicators

5. Legibility Rule: Color Scheme Alignment (Critical)

A common "Gotcha" when using custom dark themes is the "black-on-dark" text overlap. This occurs because iOS defaults to Light Mode, causing system labels and Bedrock components that rely on semantic colors (like .primary) to resolve as Black.

The Fix: Always enforce the color scheme at the root of any app using a dark surface provider:

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            MainContentView()
                .preferredColorScheme(.dark) // Enforce dark semantic colors
        }
    }
}

6. Dark Theme Considerations

For dark themes:

  • Surface colors get lighter as elevation increases
  • Text is white with varying opacity
  • Ensure sufficient contrast (WCAG AA: 4.5:1 for text)

Asset Catalog Best Practices

While you can define colors directly in Swift, the gold standard for Bedrock themes is using an Asset Catalog (.xcassets). This allows for visual editing, better system integration, and automatic Light/Dark mode switching.

1. Mandatory JSON Structure (Contents.json)

Xcode is extremely strict about the JSON schema in .colorset/Contents.json. To ensure your colors are correctly recognized (avoiding "unassigned children"), use this exact pattern:

Important

Use the "color" key for data (not "value") and "appearance": "luminosity" for theme variants.

{
  "colors" : [
    {
      "idiom" : "universal",
      "color" : {
        "color-space" : "display-p3",
        "components" : { "alpha": "1.000", "red": "0.06", "green": "0.06", "blue": "0.09" }
      }
    },
    {
      "appearances" : [ { "appearance": "luminosity", "value": "light" } ],
      "idiom" : "universal",
      "color" : {
        "color-space" : "display-p3",
        "components" : { "alpha": "1.000", "red": "0.96", "green": "0.96", "blue": "0.98" }
      }
    },
    {
      "appearances" : [ { "appearance": "luminosity", "value": "dark" } ],
      "idiom" : "universal",
      "color" : {
        "color-space" : "display-p3",
        "components" : { "alpha": "1.000", "red": "0.06", "green": "0.06", "blue": "0.09" }
      }
    }
  ],
  "info" : { "author" : "xcode", "version" : 1 }
}

2. The 3-Variant Pattern

Always provide Any (Universal), Light, and Dark variants.

  • Any: Typically matches your dark branding for high-end apps.
  • Light/Dark: Explicit luminosity overrides for standard OS behavior.

3. Folder Namespacing

Organize colors into folders (Surface/, Text/, Accent/).

  • Enable "Provides Namespace" in the folder's Contents.json.
  • This allows you to reference colors cleanly as Color("Surface/Primary") while keeping the catalog organized.
{
  "info" : { "author" : "xcode", "version" : 1 },
  "properties" : { "provides-namespace" : true }
}

Migrating Existing Views

If you have views using hardcoded colors, migrate them:

// Before
.foregroundStyle(.white)
.foregroundStyle(.white.opacity(0.65))
.background(Color(red: 0.14, green: 0.10, blue: 0.16))

// After
.foregroundStyle(AppText.primary)
.foregroundStyle(AppText.secondary)
.background(AppSurface.card)

Light Theme Support

To support light themes, create a second set of color providers:

public enum YourAppLightSurfaceColors: SurfaceColorProvider {
    public static let primary = Color.white
    public static let secondary = Color(red: 0.96, green: 0.96, blue: 0.98)
    // ... etc
}

public enum YourAppLightTextColors: TextColorProvider {
    public static let primary = Color.black
    public static let secondary = Color.black.opacity(0.65)
    // ... etc
}

Then use @Environment(\.colorScheme) to switch between themes in your views, or define typealiases that automatically select the correct theme based on system appearance.