Theming

Light/dark themes, semantic color tokens, and typography.

The app supports system / light / dark appearance with a live in-app toggle, a set of semantic color tokens that adapt to the color scheme, and a typed typography scale. Together they make rebranding a find-and-tweak exercise rather than a hunt through inline hex values. This page covers all three, plus a rebranding checklist.

The appearance toggle

ThemeController (Core/Theme/) owns the choice, mirroring LocaleController: an @Observable that persists to AppPreferences and exposes the SwiftUI value the root applies.

@Observable @MainActor
final class ThemeController {
    var selectedTheme: AppTheme {          // .system | .light | .dark
        didSet { preferences.themeTag = selectedTheme.rawValue }
    }
    var effectiveColorScheme: ColorScheme? { selectedTheme.effectiveColorScheme }
}

AppTheme.system maps to nil (defer to iOS); .light/.dark force the scheme. RootView applies it once at the top:

.preferredColorScheme(theme.effectiveColorScheme)

Because ThemeController is @Observable, changing the theme re-renders the whole tree instantly. The ThemeToggle component (a segmented Picker) is the ready-made control — drop it anywhere; the Me tab already uses it.

Semantic color tokens

Colors live in Color+Tokens.swift as .appXxx static accessors, split into two kinds. Use the tokens in views — never recompute a hex inline, so a rebrand touches one file.

Brand & semantic singletons — same in light and dark:

static let appTint = Color(hex: 0x007AFF)
static let appDestructive = Color(hex: 0xFF3B30)
static let appGreen = Color(hex: 0x10B981)

Adaptive tokens — resolve per color scheme via Color(light:dark:), which wraps a UIColor { trait in … }:

static let appGroupedBg      = Color(light: 0xF2F2F7, dark: 0x000000)
static let appGroupedSurface = Color(light: 0xFFFFFF, dark: 0x1C1C1E)
static let appSeparator      = Color(light: 0xC6C6C8, dark: 0x38383A)

Two convenience initializers back them (init(hex:) and init(light:dark:)), so adding a token is a one-liner. The palette follows iOS system-color conventions (grouped backgrounds, separators, label tiers) ported from the Expo original's lib/theme.ts.

AccentColor is the exception — it lives in Assets.xcassets/AccentColor.colorset, not in code, so a designer can retint the app's accent without recompiling. Everything else is defined in Color+Tokens.swift.

Typography

Typography.swift extends Font with a named scale wrapping the system font. Prefer these over .font(.system(size:)) so Dynamic Type and dark-mode contrast stay consistent:

TokenSize / weightUse
.appLargeTitle34 boldHome featured headings
.appTitle28 boldIn-content screen titles
.appTitle222 semiboldCard / section titles
.appHeadline17 semiboldRow primary text
.appBody15 regularBody text in cards / lists
.appCallout14 regularSubtitles, helper text
.appCaption12 regularMetadata
.appOverline12 semiboldUppercase section eyebrows (SectionHeader)
Text("Today").font(.appTitle2)

Rebranding checklist

Making the template yours:

Accent color → recolor Assets.xcassets/AccentColor.colorset (light + dark appearances).

Brand tokens → edit the singletons in Color+Tokens.swift (appTint, appGreen, …). Adjust the adaptive surface tokens if your palette isn't iOS-gray.

Type → swap the system font for a custom family in Typography.swift (one file, all call sites follow).

App icon → replace Assets.xcassets/AppIcon.appiconset, and the launch assets LaunchLogo / LaunchBackground.

Display name & bundle id → set PRODUCT_NAME (drives CFBundleName) and the bundle identifier (com.soarstarter.SoarStarterSwift today) in the target's build settings. See App Store Release.

Preview both schemes as you go — Xcode's #Preview and the canvas color-scheme toggle, or run in the simulator and flip Settings → Developer → Dark Appearance. Every adaptive token has a dark value; a hardcoded hex will betray itself in one scheme.

On this page