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:
| Token | Size / weight | Use |
|---|---|---|
.appLargeTitle | 34 bold | Home featured headings |
.appTitle | 28 bold | In-content screen titles |
.appTitle2 | 22 semibold | Card / section titles |
.appHeadline | 17 semibold | Row primary text |
.appBody | 15 regular | Body text in cards / lists |
.appCallout | 14 regular | Subtitles, helper text |
.appCaption | 12 regular | Metadata |
.appOverline | 12 semibold | Uppercase 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.
