Design tokens preserve intent across appearances. A semantic name such as primaryText describes purpose; a literal hex value describes only one rendering choice.

Primitive palette → Semantic roles → Component usage → Appearance checks
  1. 1Primitive palette
  2. 2Semantic roles
  3. 3Component usage
  4. 4Appearance checks

Work through the example

Trace one button from its semantic color to its light, dark and high-contrast values. Remove literal colors only after understanding their role.

Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.

Implementation reference

The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.

1. The Three-Tier Token Architecture

Never let a raw color or number appear at a call site. Tokens flow in one direction through three tiers:

Tier 1 — Primitive   Tier 2 — Semantic        Tier 3 — Component
(raw values)          (intent)                  (usage)
blue500  #0A84FF  →   accent                →   Button.background
gray900  #1C1C1E  →   textPrimary           →   Card.titleColor
space4   16pt     →   spacing.contentInset  →   Card.padding

Rules

  • Views reference Tier 3 or Tier 2 only. A view that names blue500 is a bug.
  • Tier 1 is private to the token module. It has no dark-mode variant — it is literally just a number.
  • Tier 2 is where light/dark, high-contrast, and theme switching resolve.
  • Adding a theme means adding one Tier 2 implementation, not editing views.

Implementation

// DesignSystem/Tokens/Primitives.swift
// Tier 1 — raw values. Never referenced from a View.
enum Primitive {
    static let blue500  = Color(hex: 0x0A84FF)
    static let blue600  = Color(hex: 0x0060DF)
    static let indigo500 = Color(hex: 0x5E5CE6)
    static let red500   = Color(hex: 0xFF3B30)
    static let green500 = Color(hex: 0x34C759)
    static let amber500 = Color(hex: 0xFF9F0A)

    // Spacing scale — a 4pt rhythm. Nothing else is permitted.
    static let space1: CGFloat = 4
    static let space2: CGFloat = 8
    static let space3: CGFloat = 12
    static let space4: CGFloat = 16
    static let space5: CGFloat = 24
    static let space6: CGFloat = 32
    static let space7: CGFloat = 48

    // Radii
    static let radiusS: CGFloat = 8
    static let radiusM: CGFloat = 16
    static let radiusL: CGFloat = 24
}

// THE canonical hex initialiser for this skill. Defined here, in the token
// layer, and nowhere else — `color-system.md` and
// `templates/common-patterns/design-system.swift` reference this file rather
// than redeclaring it. Two files declaring `init(hex: String)` with different
// bodies is not a style disagreement: copy both into one target and the
// compiler rejects it with `invalid redeclaration of 'init(hex:)'`.
extension Color {
    /// Hex as an integer literal: `Color(hex: 0x6C63FF)`.
    ///
    /// Preferred over the string form because a typo is a compile error rather
    /// than a runtime surprise — `0x6C63FZ` does not build, `"6C63FZ"` does.
    init(hex: UInt32, opacity: Double = 1) {
        self.init(
            .sRGB,
            red:   Double((hex >> 16) & 0xFF) / 255,
            green: Double((hex >>  8) & 0xFF) / 255,
            blue:  Double( hex        & 0xFF) / 255,
            opacity: opacity
        )
    }

    /// Hex as a string: `Color(hex: "6C63FF")`, with or without `#`,
    /// 6 digits (RGB) or 8 (RRGGBBAA).
    ///
    /// Exists because designers hand over strings and remote themes arrive as
    /// JSON. A malformed value traps in DEBUG and renders **magenta** in
    /// release — never black. The earlier versions of this initialiser fell
    /// back to black, which is indistinguishable from a deliberate colour and
    /// so shipped unnoticed; magenta appears nowhere in any of these palettes
    /// and is impossible to mistake for intent.
    init(hex string: String, opacity: Double = 1) {
        let cleaned = string
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "#", with: "")

        var value: UInt64 = 0
        let scanned = Scanner(string: cleaned).scanHexInt64(&value)

        switch (scanned, cleaned.count) {
        case (true, 6):
            self.init(hex: UInt32(truncatingIfNeeded: value), opacity: opacity)
        case (true, 8):
            let alpha = Double(value & 0xFF) / 255
            self.init(hex: UInt32(truncatingIfNeeded: value >> 8), opacity: opacity * alpha)
        default:
            assertionFailure("Malformed hex colour literal: \(string)")
            self.init(hex: 0xFF00FF, opacity: opacity)
        }
    }
}
// DesignSystem/Tokens/Theme.swift
// Tier 2 — semantic intent. This is the swappable layer.
protocol Theme: Sendable {
    // Surfaces
    var background: Color { get }        // the page
    var surface: Color { get }           // cards, sheets
    var surfaceElevated: Color { get }   // popovers, menus

    // Content — must meet contrast against the surface it sits on
    var textPrimary: Color { get }
    var textSecondary: Color { get }
    var textOnAccent: Color { get }

    // Intent
    var accent: Color { get }
    var accentPressed: Color { get }
    var destructive: Color { get }
    var success: Color { get }
    var warning: Color { get }

    // Separators and elevation
    var separator: Color { get }
    var shadow: Color { get }
}

struct OceanTheme: Theme {
    // Apple's semantic colors already resolve light/dark AND increased contrast.
    // Prefer them for surfaces and text; reserve custom hex for brand accents.
    var background      = Color(.systemBackground)
    var surface         = Color(.secondarySystemBackground)
    var surfaceElevated = Color(.tertiarySystemBackground)

    var textPrimary   = Color(.label)
    var textSecondary = Color(.secondaryLabel)
    var textOnAccent  = Color.white

    var accent        = Primitive.blue500
    var accentPressed = Primitive.blue600
    var destructive   = Color(.systemRed)
    var success       = Color(.systemGreen)
    var warning       = Color(.systemOrange)

    var separator = Color(.separator)
    var shadow    = Color.black.opacity(0.08)
}
// DesignSystem/Tokens/Spacing.swift — Tier 2 for layout
enum Space {
    static let hairline    = Primitive.space1   // icon-to-label
    static let tight       = Primitive.space2   // within a control
    static let element     = Primitive.space3   // between related elements
    static let contentInset = Primitive.space4  // card padding, screen margins
    static let section     = Primitive.space5   // between sections
    static let major       = Primitive.space6   // above a page title
}

enum Radius {
    static let control = Primitive.radiusS      // buttons, chips
    static let card    = Primitive.radiusM      // cards, tiles
    static let sheet   = Primitive.radiusL      // modals
}

Injecting the theme

private struct ThemeKey: EnvironmentKey {
    static let defaultValue: any Theme = OceanTheme()
}

extension EnvironmentValues {
    var theme: any Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            RootView().environment(\.theme, OceanTheme())
        }
    }
}

Tier 3 — component tokens as ViewModifiers

struct CardStyle: ViewModifier {
    @Environment(\.theme) private var theme

    func body(content: Content) -> some View {
        content
            .padding(Space.contentInset)
            .background(theme.surface, in: .rect(cornerRadius: Radius.card))
            .shadow(color: theme.shadow, radius: 8, y: 4)
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

// Usage — no raw values anywhere.
VStack(alignment: .leading, spacing: Space.element) {
    Text("Monthly total").font(.headline).foregroundStyle(theme.textPrimary)
    Text("$1,240").font(.largeTitle.bold()).foregroundStyle(theme.accent)
}
.cardStyle()

Anti-patterns

// WRONG — raw values at the call site. Changing the brand means grepping.
.padding(16)
.background(Color(red: 0.04, green: 0.52, blue: 1.0))
.cornerRadius(16)

// WRONG — a Tier 1 primitive leaking into a view.
.foregroundStyle(Primitive.blue500)

// WRONG — a semantic name that describes appearance, not intent.
var lightGray: Color { … }        // what happens in dark mode?
var textSecondary: Color { … }    // correct

// RIGHT
.padding(Space.contentInset)
.background(theme.accent, in: .rect(cornerRadius: Radius.card))

Acceptance and failure review

Checkpoint What to inspect If it does not match
Primitive palette Confirm the input and environment Preserve the failure and return to this step
Semantic roles Inspect the intermediate artifact Preserve the failure and return to this step
Component usage Run the focused check Preserve the failure and return to this step
Appearance checks Record the observed result Preserve the failure and return to this step

Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.

Evidence and limits

Generated asset structure has evidence; visual quality still needs checks in the actual app.

This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The series evidence record separates executed checks from exercises and blocked environments.

Inspect the source used in this lesson.

What to do next

Next: SwiftUI prompt practice: 25 scoped exercises and acceptance checks