Adopt three-tier design tokens in Swift
Context
SwiftUI apps accumulate hardcoded colors, spacing values, and font sizes across components. Without a system, design changes mean hunting through multiple files, dark mode support is inconsistent, and there's no semantic meaning attached to values.
We considered JSON-based design token tooling (Style Dictionary, SwiftTokenGen) but the Swift ecosystem for these is immature and poorly maintained. SwiftUI's environment system provides a natural distribution mechanism without external dependencies.
Decision
Implement a two-tier design token system in pure Swift.
Layer 1: Semantic tokens (purpose-driven values)
Purpose-driven names that map to platform-adaptive system colors, typography, spacing, border radius, and shadows:
SemanticTokens.Colors.surfacePrimary // adapts to iOS/macOS, light/dark
SemanticTokens.Colors.brandPrimary // brand color
SemanticTokens.Spacing.componentPaddingMD // 16pt
SemanticTokens.Typography.headingMedium // system font, 24pt semibold
Platform adaptation uses conditional compilation:
#if canImport(UIKit)
public static let surfacePrimary = Color(uiColor: .systemBackground)
#elseif canImport(AppKit)
public static let surfacePrimary = Color(nsColor: .windowBackgroundColor)
#endif
Layer 2: Component tokens (pre-configured styles)
Component-specific values that reference semantic tokens:
ComponentTokens.Button.Primary.backgroundColor = SemanticTokens.Colors.brandPrimary
ComponentTokens.Card.borderRadius = SemanticTokens.BorderRadius.xlarge
ComponentTokens.Input.borderColorError = SemanticTokens.Colors.intentDanger
The initial version was a flat collection of named colors and values. We restructured it into a two-tier semantic system — the three-tier plan from the original ADR dropped the raw values layer because semantic names are self-documenting and the extra indirection wasn't worth it. A primitives layer can be added later if the same raw value shows up in too many semantic tokens.
Consequences
Design changes propagate from one place. Dark mode and platform adaptation are handled at the semantic layer. Components get consistent styling without repeating design decisions.
The system can grow complex if tokens proliferate without curation. New tokens should have clear semantic purpose — if you're reaching for a name like spacing7, it's probably not semantic enough.