Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-17 22:20:59 -06:00
parent fc4e283124
commit 5f404873a1

View File

@ -1,164 +1,210 @@
# App Theme Guide # 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. This guide defines the Bedrock theming standard: all theme colors come from Asset Catalog color sets, never from inline Swift color literals.
## Non-Negotiable Rule
- Use named color assets for every theme color.
- Configure Any/Light/Dark variants in each `.colorset`.
- Do not use `Color(red:green:blue:)`, `.white`, `.black`, `.red`, `.blue`, `UIColor(...)`, or hex initializers in theme code.
## Overview ## Overview
The theming system consists of: The Bedrock theme system is made of:
1. **Color Protocols** — Define what colors your theme must provide 1. Color provider protocols (semantic color contracts)
2. **Color Enums** — Your app's concrete color implementations 2. Asset-backed color tokens (single source of truth)
3. **Theme Enum** — Combines all color providers into one theme 3. Theme enum wiring providers together
4. **Typealiases** — Provide clean, short names for use in views 4. Typealiases for clean view usage
## Step 1: Create Your Theme File ## Step 1: Create Namespaced Color Assets
Create a file called `YourAppTheme.swift` in your app's `Shared/Theme/` folder. Create these folders in `Assets.xcassets` and enable `Provides Namespace` on each folder:
- `Surface/`
- `Text/`
- `Accent/`
- `Button/`
- `Status/`
- `Border/`
- `Interactive/`
Create color sets for each protocol property:
| Provider | Property | Asset Name |
|----------|----------|------------|
| `SurfaceColorProvider` | `primary` | `Surface/primary` |
| `SurfaceColorProvider` | `secondary` | `Surface/secondary` |
| `SurfaceColorProvider` | `tertiary` | `Surface/tertiary` |
| `SurfaceColorProvider` | `overlay` | `Surface/overlay` |
| `SurfaceColorProvider` | `card` | `Surface/card` |
| `SurfaceColorProvider` | `groupedFill` | `Surface/groupedFill` |
| `SurfaceColorProvider` | `sectionFill` | `Surface/sectionFill` |
| `TextColorProvider` | `primary` | `Text/primary` |
| `TextColorProvider` | `secondary` | `Text/secondary` |
| `TextColorProvider` | `tertiary` | `Text/tertiary` |
| `TextColorProvider` | `disabled` | `Text/disabled` |
| `TextColorProvider` | `placeholder` | `Text/placeholder` |
| `TextColorProvider` | `inverse` | `Text/inverse` |
| `AccentColorProvider` | `primary` | `Accent/primary` |
| `AccentColorProvider` | `light` | `Accent/light` |
| `AccentColorProvider` | `dark` | `Accent/dark` |
| `AccentColorProvider` | `secondary` | `Accent/secondary` |
| `ButtonColorProvider` | `primaryLight` | `Button/primaryLight` |
| `ButtonColorProvider` | `primaryDark` | `Button/primaryDark` |
| `ButtonColorProvider` | `secondary` | `Button/secondary` |
| `ButtonColorProvider` | `destructive` | `Button/destructive` |
| `ButtonColorProvider` | `cancelText` | `Button/cancelText` |
| `StatusColorProvider` | `success` | `Status/success` |
| `StatusColorProvider` | `warning` | `Status/warning` |
| `StatusColorProvider` | `error` | `Status/error` |
| `StatusColorProvider` | `info` | `Status/info` |
| `BorderColorProvider` | `subtle` | `Border/subtle` |
| `BorderColorProvider` | `standard` | `Border/standard` |
| `BorderColorProvider` | `emphasized` | `Border/emphasized` |
| `BorderColorProvider` | `selected` | `Border/selected` |
| `InteractiveColorProvider` | `selected` | `Interactive/selected` |
| `InteractiveColorProvider` | `hover` | `Interactive/hover` |
| `InteractiveColorProvider` | `pressed` | `Interactive/pressed` |
| `InteractiveColorProvider` | `focus` | `Interactive/focus` |
Each `.colorset` must define Any + Light + Dark appearances.
## Step 2: Add Typed Asset Access
Create `YourAppTheme.swift` in your app target:
```swift ```swift
import SwiftUI import SwiftUI
import Bedrock import Bedrock
// MARK: - YourApp Surface Colors // MARK: - Asset-backed Tokens
public enum ThemeAssetColor {
public enum Surface {
public static let primary = Color("Surface/primary")
public static let secondary = Color("Surface/secondary")
public static let tertiary = Color("Surface/tertiary")
public static let overlay = Color("Surface/overlay")
public static let card = Color("Surface/card")
public static let groupedFill = Color("Surface/groupedFill")
public static let sectionFill = Color("Surface/sectionFill")
}
public enum Text {
public static let primary = Color("Text/primary")
public static let secondary = Color("Text/secondary")
public static let tertiary = Color("Text/tertiary")
public static let disabled = Color("Text/disabled")
public static let placeholder = Color("Text/placeholder")
public static let inverse = Color("Text/inverse")
}
public enum Accent {
public static let primary = Color("Accent/primary")
public static let light = Color("Accent/light")
public static let dark = Color("Accent/dark")
public static let secondary = Color("Accent/secondary")
}
public enum Button {
public static let primaryLight = Color("Button/primaryLight")
public static let primaryDark = Color("Button/primaryDark")
public static let secondary = Color("Button/secondary")
public static let destructive = Color("Button/destructive")
public static let cancelText = Color("Button/cancelText")
}
public enum Status {
public static let success = Color("Status/success")
public static let warning = Color("Status/warning")
public static let error = Color("Status/error")
public static let info = Color("Status/info")
}
public enum Border {
public static let subtle = Color("Border/subtle")
public static let standard = Color("Border/standard")
public static let emphasized = Color("Border/emphasized")
public static let selected = Color("Border/selected")
}
public enum Interactive {
public static let selected = Color("Interactive/selected")
public static let hover = Color("Interactive/hover")
public static let pressed = Color("Interactive/pressed")
public static let focus = Color("Interactive/focus")
}
}
```
If your assets are in a Swift package target, use the package bundle:
```swift
Color("Surface/primary", bundle: .module)
```
## Step 3: Build Providers from Asset Tokens
```swift
import SwiftUI
import Bedrock
/// Surface colors for backgrounds and containers.
public enum YourAppSurfaceColors: SurfaceColorProvider { public enum YourAppSurfaceColors: SurfaceColorProvider {
/// Primary background - darkest/base color public static let primary = ThemeAssetColor.Surface.primary
public static let primary = Color(red: 0.08, green: 0.06, blue: 0.10) public static let secondary = ThemeAssetColor.Surface.secondary
public static let tertiary = ThemeAssetColor.Surface.tertiary
/// Secondary/elevated surface - slightly lighter public static let overlay = ThemeAssetColor.Surface.overlay
public static let secondary = Color(red: 0.12, green: 0.08, blue: 0.14) public static let card = ThemeAssetColor.Surface.card
public static let groupedFill = ThemeAssetColor.Surface.groupedFill
/// Tertiary/card surface - more elevated public static let sectionFill = ThemeAssetColor.Surface.sectionFill
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 { public enum YourAppTextColors: TextColorProvider {
/// Primary text - highest emphasis (usually white for dark themes) public static let primary = ThemeAssetColor.Text.primary
public static let primary = Color.white public static let secondary = ThemeAssetColor.Text.secondary
public static let tertiary = ThemeAssetColor.Text.tertiary
/// Secondary text - muted public static let disabled = ThemeAssetColor.Text.disabled
public static let secondary = Color.white.opacity(Design.Opacity.accent) public static let placeholder = ThemeAssetColor.Text.placeholder
public static let inverse = ThemeAssetColor.Text.inverse
/// 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 { public enum YourAppAccentColors: AccentColorProvider {
/// Primary accent - your main brand color public static let primary = ThemeAssetColor.Accent.primary
public static let primary = Color(red: 0.85, green: 0.25, blue: 0.45) public static let light = ThemeAssetColor.Accent.light
public static let dark = ThemeAssetColor.Accent.dark
/// Light variant - softer version public static let secondary = ThemeAssetColor.Accent.secondary
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 { public enum YourAppButtonColors: ButtonColorProvider {
/// Light gradient color for primary buttons public static let primaryLight = ThemeAssetColor.Button.primaryLight
public static let primaryLight = Color(red: 0.95, green: 0.40, blue: 0.55) public static let primaryDark = ThemeAssetColor.Button.primaryDark
public static let secondary = ThemeAssetColor.Button.secondary
/// Dark gradient color for primary buttons public static let destructive = ThemeAssetColor.Button.destructive
public static let primaryDark = Color(red: 0.75, green: 0.20, blue: 0.40) public static let cancelText = ThemeAssetColor.Button.cancelText
/// 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 { public enum YourAppStatusColors: StatusColorProvider {
/// Success/positive state public static let success = ThemeAssetColor.Status.success
public static let success = Color(red: 0.2, green: 0.8, blue: 0.4) public static let warning = ThemeAssetColor.Status.warning
public static let error = ThemeAssetColor.Status.error
/// Warning state public static let info = ThemeAssetColor.Status.info
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 { public enum YourAppBorderColors: BorderColorProvider {
/// Subtle border - barely visible public static let subtle = ThemeAssetColor.Border.subtle
public static let subtle = Color.white.opacity(Design.Opacity.subtle) public static let standard = ThemeAssetColor.Border.standard
public static let emphasized = ThemeAssetColor.Border.emphasized
/// Standard border public static let selected = ThemeAssetColor.Border.selected
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 { public enum YourAppInteractiveColors: InteractiveColorProvider {
/// Selected state public static let selected = ThemeAssetColor.Interactive.selected
public static let selected = YourAppAccentColors.primary.opacity(Design.Opacity.selection) public static let hover = ThemeAssetColor.Interactive.hover
public static let pressed = ThemeAssetColor.Interactive.pressed
/// Hover/highlight state public static let focus = ThemeAssetColor.Interactive.focus
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 enum YourAppTheme: AppColorTheme {
public typealias Surface = YourAppSurfaceColors public typealias Surface = YourAppSurfaceColors
public typealias Text = YourAppTextColors public typealias Text = YourAppTextColors
@ -170,21 +216,9 @@ public enum YourAppTheme: AppColorTheme {
} }
``` ```
## Step 2: Create Convenience Typealiases ## Step 4: Add Convenience Typealiases
Add short typealiases at the bottom of your theme file for cleaner usage in views:
```swift ```swift
// 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 AppSurface = YourAppSurfaceColors
typealias AppText = YourAppTextColors typealias AppText = YourAppTextColors
typealias AppAccent = YourAppAccentColors typealias AppAccent = YourAppAccentColors
@ -194,214 +228,29 @@ typealias AppBorder = YourAppBorderColors
typealias AppInteractive = YourAppInteractiveColors typealias AppInteractive = YourAppInteractiveColors
``` ```
## Step 3: Use Theme Colors in Views ## Step 5: Use Theme Colors in Views
### Background Colors
```swift ```swift
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
```swift
// Primary text (titles, important content)
Text("Settings") Text("Settings")
.foregroundStyle(AppText.primary) .foregroundStyle(AppText.primary)
// Secondary text (subtitles, descriptions)
Text("Adjust your preferences") Text("Adjust your preferences")
.foregroundStyle(AppText.secondary) .foregroundStyle(AppText.secondary)
// Tertiary text (hints, captions) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
Text("Last updated 5 minutes ago") SettingsToggle(
.foregroundStyle(AppText.tertiary)
// Disabled text
Text("Feature unavailable")
.foregroundStyle(AppText.disabled)
```
### Accent Colors
```swift
// Toggles and interactive elements
SettingsToggle(
title: "Enable Feature", title: "Enable Feature",
subtitle: "Turn this feature on or off", subtitle: "Turn this feature on or off",
isOn: $isEnabled, isOn: $isEnabled,
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
// Buttons
Button("Save") { save() }
.foregroundStyle(AppAccent.primary)
// Section headers
SettingsSectionHeader(
title: "Display",
systemImage: "eye",
accentColor: AppAccent.primary
)
```
> For settings layout alignment, place rows inside `SettingsCard` and use `SettingsCardRow` + `SettingsDivider` for custom row content (see `SETTINGS_GUIDE.md`).
### Status Colors
```swift
// 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
```swift
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:
```swift
// ✅ 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:
```swift
// ✅ 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:
```swift
@main
struct YourApp: App {
var body: some Scene {
WindowGroup {
MainContentView()
.preferredColorScheme(.dark) // Enforce dark semantic colors
}
}
} }
.background(AppSurface.primary)
``` ```
### 6. Dark Theme Considerations ## Asset JSON Pattern (Any + Light + Dark)
For dark themes: Use this schema for each `.colorset/Contents.json`:
- 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.
```json ```json
{ {
@ -410,7 +259,7 @@ Xcode is extremely strict about the JSON schema in `.colorset/Contents.json`. To
"idiom" : "universal", "idiom" : "universal",
"color" : { "color" : {
"color-space" : "display-p3", "color-space" : "display-p3",
"components" : { "alpha": "1.000", "red": "0.06", "green": "0.06", "blue": "0.09" } "components" : { "alpha": "1.000", "red": "<any-red>", "green": "<any-green>", "blue": "<any-blue>" }
} }
}, },
{ {
@ -418,7 +267,7 @@ Xcode is extremely strict about the JSON schema in `.colorset/Contents.json`. To
"idiom" : "universal", "idiom" : "universal",
"color" : { "color" : {
"color-space" : "display-p3", "color-space" : "display-p3",
"components" : { "alpha": "1.000", "red": "0.96", "green": "0.96", "blue": "0.98" } "components" : { "alpha": "1.000", "red": "<light-red>", "green": "<light-green>", "blue": "<light-blue>" }
} }
}, },
{ {
@ -426,7 +275,7 @@ Xcode is extremely strict about the JSON schema in `.colorset/Contents.json`. To
"idiom" : "universal", "idiom" : "universal",
"color" : { "color" : {
"color-space" : "display-p3", "color-space" : "display-p3",
"components" : { "alpha": "1.000", "red": "0.06", "green": "0.06", "blue": "0.09" } "components" : { "alpha": "1.000", "red": "<dark-red>", "green": "<dark-green>", "blue": "<dark-blue>" }
} }
} }
], ],
@ -434,57 +283,26 @@ Xcode is extremely strict about the JSON schema in `.colorset/Contents.json`. To
} }
``` ```
### 2. The 3-Variant Pattern ## Migration Rule
Always provide **Any (Universal)**, **Light**, and **Dark** variants. Any existing literal or system color usage should be replaced by the matching theme token.
- **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.
```json
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "provides-namespace" : true }
}
```
## Migrating Existing Views
If you have views using hardcoded colors, migrate them:
```swift ```swift
// Before // Preferred
.foregroundStyle(.white)
.foregroundStyle(.white.opacity(0.65))
.background(Color(red: 0.14, green: 0.10, blue: 0.16))
// After
.foregroundStyle(AppText.primary) .foregroundStyle(AppText.primary)
.foregroundStyle(AppText.secondary) .foregroundStyle(AppText.secondary)
.background(AppSurface.card) .background(AppSurface.card)
``` ```
## Light Theme Support ## Validation Checklist
To support light themes, create a second set of color providers: - Every provider value points to `ThemeAssetColor.*`.
- Every `ThemeAssetColor` entry points to `Color("Namespace/name")`.
- Every referenced asset includes Any + Light + Dark variants.
- No Swift files define inline literal colors.
```swift Quick audits:
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 { ```bash
public static let primary = Color.black rg 'Color\\(red:|Color\\(\\.sRGB|UIColor\\(|\\.white|\\.black|\\.red|\\.blue|#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})' .
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.