How-to · Swift

Wire Sitka's ThemeManager in SwiftUI

Set up the Sitka theme toggle, apply the effectiveAccent pattern, keep tokens adaptive for light and dark mode, and forward ThemeManager correctly through macOS sheets.

Overview

Sitka ships with a ThemeManager class that lets a SwiftUI app switch between a user-chosen accent colour and the locked Sitka brand palette at runtime. It is not a saved preference — it is a live preview tool that defaults to false on every launch. The toggle is typically exposed in a debug overlay or internal settings screen.

Getting it right requires five things working together: creating the manager, injecting it into the environment, rebuilding the view tree on change, writing adaptive tokens, and forwarding the manager into every sheet. Miss any one of these and the toggle will either do nothing, corrupt colours in light mode, or crash at runtime.

FileRole
ThemeManager.swiftObservableObject with useSitkaDefaults: Bool
SitkaDebugModifier.swiftColor.sitka* aliases + convenience extensions
SitkaTokens.swiftAll typed token constants (colors, spacing, radius …)
App entry pointCreates ThemeManager, injects it as an EnvironmentObject

1 · ThemeManager setup

Create ThemeManager.swift in your Shared/Theme/ folder. The @Published property on an ObservableObject causes SwiftUI to re-render any view that reads it via @EnvironmentObject whenever the value changes.

ThemeManager.swift
swift
import SwiftUI

final class ThemeManager: ObservableObject {
    /// Defaults to false every launch — this is a preview tool, not a saved preference.
    @Published var useSitkaDefaults = false
}

Inject at the app root (iOS)

Create a single ThemeManager instance at the app entry point and inject it into the environment with .environmentObject(). Also wire .tint()through the manager so system-rendered controls (native buttons, toggle switches, back-chevrons) also switch colour.

JobFlowApp.swift (iOS)
swift
@main
struct JobFlowApp: App {
    @StateObject private var themeManager = ThemeManager()
    @StateObject private var appSettings  = AppSettings()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(themeManager)
                .environmentObject(appSettings)
                // Wire the root tint through ThemeManager so all native
                // controls respond to the toggle — not just custom views.
                .tint(themeManager.useSitkaDefaults
                    ? Color.sitkaPrimary
                    : appSettings.currentAccent)
        }
    }
}

Force a full tree rebuild when the toggle flips

SwiftUI caches rendered views aggressively. Without .id() on the root, views that were already on screen keep their old colours even after useSitkaDefaults changes. Applying .id(themeManager.useSitkaDefaults) tells SwiftUI to treat the whole content view as a new view whenever the boolean flips — guaranteeing a clean re-render.

ContentView.swift
swift
struct ContentView: View {
    @EnvironmentObject private var themeManager: ThemeManager

    var body: some View {
        // The .id() modifier causes SwiftUI to discard and recreate the
        // entire view tree when useSitkaDefaults changes. Without it, cached
        // views keep stale colours after the toggle flips.
        MainTabView()
            .id(themeManager.useSitkaDefaults)
    }
}

Inject at the app root (macOS)

JobFlowApp.swift (macOS)
swift
@main
struct JobFlowApp: App {
    @StateObject private var themeManager = ThemeManager()
    @StateObject private var appSettings  = AppSettings()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(themeManager)
                .environmentObject(appSettings)
        }
    }
}

2 · Adaptive tokens (light & dark mode)

Sitka is dark-first. Fixed hex values for text tokens — like Color.white — look correct in dark mode but become invisible against a white background in light mode. All text and surface tokens must resolve to adaptive system colors.

The SitkaTokens.swift Color enum uses static hex values, which are correct for brand colours (accent, success, warning, error) that are intentionally vivid regardless of mode. But text and surface tokens must use adaptive platform values so they automatically flip with the system appearance.

SitkaTokens+Adaptive.swift
swift
import SwiftUI

// Add this file alongside SitkaTokens.swift.
// These properties REPLACE the hex-based textPrimary / textSecondary / surface
// values for any view that may appear in both light and dark mode.

extension SitkaTokens.Color {
    // Text — adaptive: white in dark mode, near-black in light mode
    public static var textPrimary:    Color { Color(.label) }
    public static var textSecondary:  Color { Color(.secondaryLabel) }
    public static var textTertiary:   Color { Color(.tertiaryLabel) }

    // Surfaces — adaptive: near-black in dark, near-white in light
    public static var adaptiveBackground:  Color { Color(.systemBackground) }
    public static var adaptiveSurface:     Color { Color(.secondarySystemBackground) }
    public static var adaptiveSeparator:   Color { Color(.separator) }
}

For macOS, use NSColor equivalents — see the macOS library page for the full mapping table.

Rule of thumb

Token typeUse hex?Reason
Brand accent (#8B6DFF)YesSame vivid purple in both modes
Semantic (success/warning/error)YesFixed meaning, same hue both modes
Text primary / secondaryNoMust adapt — white in dark, near-black in light
Surface / backgroundNoMust adapt — dark panel vs. white card
Separator / borderNoSystem color adapts hairline weight automatically

3 · SitkaDebugModifier and Color.sitka* aliases

SitkaDebugModifier.swift provides short-form Color.sitka*aliases so views don't reach into the SFColor or SitkaTokens.Color namespace directly. It also houses the ThemeManager class in some project layouts.

SitkaDebugModifier.swift
swift
import SwiftUI

// Short aliases — use these in views instead of SitkaTokens.Color.*
extension Color {
    // Brand accent (fixed — same vivid colour in both modes)
    static let sitkaPrimary = SitkaTokens.Color.accent

    // Semantic status colours (fixed)
    static let sitkaSuccess = SitkaTokens.Color.success
    static let sitkaWarning = SitkaTokens.Color.warning
    static let sitkaDanger  = SitkaTokens.Color.error

    // Adaptive text (these MUST be computed vars, not lets)
    static var sitkaLabel:          Color { SitkaTokens.Color.textPrimary }
    static var sitkaSecondaryLabel: Color { SitkaTokens.Color.textSecondary }
}

Brand/semantic colours can be static let because they never change. Adaptive tokens that call Color(.label) must be static var — a let captures the value once at startup and will not respond to system appearance changes.

4 · The effectiveAccent pattern

When useSitkaDefaults is true, views should use the locked Sitka brand accent. When it is false, they should use the user's chosen colour from AppSettings. The computed property effectiveAccent encodes this decision in one place.

Any view that shows an accent colour
swift
struct ExampleView: View {
    @EnvironmentObject private var themeManager: ThemeManager
    @EnvironmentObject private var appSettings:  AppSettings

    // Declare once per view — reads the toggle and returns the right colour
    private var effectiveAccent: Color {
        themeManager.useSitkaDefaults
            ? Color.sitkaPrimary
            : appSettings.currentAccent
    }

    var body: some View {
        VStack {
            // Use effectiveAccent wherever you previously used appSettings.currentAccent
            ProgressView(value: 0.7)
                .tint(effectiveAccent)

            Button("Continue") { }
                .foregroundStyle(effectiveAccent)

            Circle()
                .fill(effectiveAccent.opacity(0.15))
                .overlay(Image(systemName: "star.fill").foregroundStyle(effectiveAccent))
        }
    }
}

Where to add it

Every view that directly reads appSettings.currentAccent needs the effectiveAccent property. Common culprits:

  • GoalStreakViewprogress rings, streak counter tint
  • AnalyticsView (iOS & macOS)chart series color, axis labels
  • AddJobView (macOS)primary action button, field focus ring
  • JobDetailView, JobsView, KanbanView, FunnelViewstatus chips, selected states
  • InterviewRoundsView, ResumeVaultView, ImportJobsViewicon tints, CTA buttons
  • PaywallViewhighlight tier border, pricing badge
  • ContentViewtab bar selected icon (iOS), sidebar selection (macOS)

The root .tint() modifier (wired in step 1 above) handles system-rendered controls globally. You only need effectiveAccent in views that explicitly read appSettings.currentAccent for custom drawing.

5 · Routing GlassTheme.accent through ThemeManager

GlassTheme is a static struct — it reads AppSettings.shared.currentAccent directly and has no connection to ThemeManager. Any view that uses GlassTheme.accentwill always show the user's custom colour even when Sitka mode is on.

The fix is to make GlassTheme.accent a passthrough that takes the resolved colour at the call site, or to add a ThemeManager-aware variant:

GlassTheme.swift — updated
swift
struct GlassTheme {
    // Legacy: reads directly from AppSettings — does NOT respond to ThemeManager
    static var accent: Color { AppSettings.shared.currentAccent }

    // Preferred: call with the resolved effectiveAccent from the calling view
    static func accentOverlay(_ color: Color, opacity: Double = 0.15) -> Color {
        color.opacity(opacity)
    }

    // Convenience for views that have EnvironmentObject access
    static func resolvedAccent(
        themeManager: ThemeManager,
        appSettings: AppSettings
    ) -> Color {
        themeManager.useSitkaDefaults
            ? Color.sitkaPrimary
            : appSettings.currentAccent
    }
}

// In your view:
struct MacSectionHeader: View {
    @EnvironmentObject private var themeManager: ThemeManager
    @EnvironmentObject private var appSettings:  AppSettings

    var body: some View {
        // Use resolvedAccent instead of GlassTheme.accent
        let accent = GlassTheme.resolvedAccent(
            themeManager: themeManager,
            appSettings: appSettings
        )
        HStack {
            Image(systemName: "sparkles")
                .foregroundStyle(accent)
            // …
        }
    }
}

6 · Environment propagation in macOS sheets

On macOS, any sheet or window that manually declares .environmentObject() must include the complete set of environment objects — including themeManager. Omitting it causes a runtime crash when the presented view tries to read ThemeManager from the environment.

This is the most common cause of "works on iOS, crashes on macOS" bugs after adding ThemeManager. macOS ContentView often presents sheets with explicit environment declarations. Every one of those chains must include all three managers.

ContentView.swift (macOS) — incorrect
swift
// ❌ themeManager is missing — AddJobView crashes if it reads ThemeManager
.sheet(isPresented: $showAddJob) {
    AddJobView()
        .environmentObject(appSettings)
        .environmentObject(subscriptionService)
}
ContentView.swift (macOS) — correct
swift
// ✅ All three environment objects forwarded
.sheet(isPresented: $showAddJob) {
    AddJobView()
        .environmentObject(themeManager)       // ← required
        .environmentObject(appSettings)
        .environmentObject(subscriptionService)
}

// Apply the same fix to every sheet, popover, and fullScreenCover:
.sheet(isPresented: $showPaywall) {
    PaywallView()
        .environmentObject(themeManager)       // ← required
        .environmentObject(appSettings)
        .environmentObject(subscriptionService)
}

Why the root injection isn't enough

On iOS, .sheet()inherits the parent's environment automatically. On macOS, sheets presented from a WindowGroup run in a separate context and do not inherit the parent environment unless you explicitly forward it. This is a macOS-only behaviour and not a SwiftUI bug.

Checklist

ItemFile / location
ThemeManager created as @StateObject at app rootApp entry point
.environmentObject(themeManager) on root WindowGroupApp entry point
.tint() wired through ThemeManager on iOS rootApp entry point
.id(themeManager.useSitkaDefaults) on ContentViewContentView.swift
Text/surface tokens use adaptive Color(.label) — not .whiteSitkaTokens+Adaptive.swift
Color.sitkaLabel is a static var (not let)SitkaDebugModifier.swift
All views reading currentAccent use effectiveAccent insteadEach affected view
GlassTheme.accent replaced with resolvedAccent(_:)GlassTheme.swift
All macOS sheets forward themeManager environmentObjectContentView.swift (macOS)