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.
| File | Role |
|---|---|
| ThemeManager.swift | ObservableObject with useSitkaDefaults: Bool |
| SitkaDebugModifier.swift | Color.sitka* aliases + convenience extensions |
| SitkaTokens.swift | All typed token constants (colors, spacing, radius …) |
| App entry point | Creates 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.
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.
@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.
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)
@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.
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 type | Use hex? | Reason |
|---|---|---|
| Brand accent (#8B6DFF) | Yes | Same vivid purple in both modes |
| Semantic (success/warning/error) | Yes | Fixed meaning, same hue both modes |
| Text primary / secondary | No | Must adapt — white in dark, near-black in light |
| Surface / background | No | Must adapt — dark panel vs. white card |
| Separator / border | No | System 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.
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.
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:
- →
GoalStreakView— progress rings, streak counter tint - →
AnalyticsView (iOS & macOS)— chart series color, axis labels - →
AddJobView (macOS)— primary action button, field focus ring - →
JobDetailView, JobsView, KanbanView, FunnelView— status chips, selected states - →
InterviewRoundsView, ResumeVaultView, ImportJobsView— icon tints, CTA buttons - →
PaywallView— highlight tier border, pricing badge - →
ContentView— tab 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:
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.
// ❌ themeManager is missing — AddJobView crashes if it reads ThemeManager
.sheet(isPresented: $showAddJob) {
AddJobView()
.environmentObject(appSettings)
.environmentObject(subscriptionService)
}// ✅ 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
| Item | File / location |
|---|---|
| ThemeManager created as @StateObject at app root | App entry point |
| .environmentObject(themeManager) on root WindowGroup | App entry point |
| .tint() wired through ThemeManager on iOS root | App entry point |
| .id(themeManager.useSitkaDefaults) on ContentView | ContentView.swift |
| Text/surface tokens use adaptive Color(.label) — not .white | SitkaTokens+Adaptive.swift |
| Color.sitkaLabel is a static var (not let) | SitkaDebugModifier.swift |
| All views reading currentAccent use effectiveAccent instead | Each affected view |
| GlassTheme.accent replaced with resolvedAccent(_:) | GlassTheme.swift |
| All macOS sheets forward themeManager environmentObject | ContentView.swift (macOS) |