All Articles
CategorySwiftUI
Reading Time
9 min read
Published
2026-10-15
Word Count
1,629words

Grab a coffee — this one is a deep dive!

DesignSystemPro 2.0: tokens, themes and tests for SwiftUI

Summary

How DesignSystemPro 2.0 structures color and type tokens, why its rendering tests use fingerprints, and what a flaky toast test taught about async tests.

  • A design system is only worth something if the right color and the right size show up on every screen, in light mode and in dark mode, at every text size.
  • PrimitiveColors is the raw palette (grays, blues, greens and so on).
  • Add the package with Swift Package Manager (from: "2.0.0"), import DesignSystemPro, and wrap your root view in DSApp { ContentView() } to install the theme provider and the toast overlay.
  • Source code: [github.com/muhittincamdali/SwiftUI-Design-System-Pro](https://github.com/muhittincamdali/SwiftUI-Design-System-Pro)
DesignSystemPro 2.0: tokens, themes and tests for SwiftUI

Table of Contents


The problem

A design system is only worth something if the right color and the right size show up on every

screen, in light mode and in dark mode, at every text size. Writing a DSButton is easy. Knowing that

its label is still readable on the dark-mode accent color, or that a theme switch actually reaches the

views below it, is harder.

DesignSystemPro is a Swift package that bundles three things for SwiftUI apps on iOS, macOS, tvOS,

watchOS and visionOS: design tokens (colors, typography, spacing, borders, shadows, motion), a theme

engine with light, dark, preset and brand themes, and about 40 component views built on the tokens.

Release 2.0.0 (14 October 2026) is also a recovery. The CHANGELOG says it directly: between February

and June 2026 the repository went through a rewrite and then a clean-up that replaced most of the

sources with placeholder files. 2.0 restores the February token, theme and component code, fixes it

and tests it. Its API is not compatible with 1.0.0.

That history shaped the release. The code had existed before without tests, and the bugs described

below had been in it all along. So the main work in 2.0 was making each claim in the README something

a test checks.

Design decisions and alternatives

Two layers of color tokens

Colors live in two layers. PrimitiveColors is the raw palette (grays, blues, greens and so on).

SemanticColorToken gives UI meaning: ColorTokens.Background.primary,

ColorTokens.Foreground.onAccent, ColorTokens.Accent.primary, ColorTokens.Status.success.

Components only use the semantic layer.

The alternative was named colors in an asset catalog. That gives visual editing in Xcode, but in a

Swift package it needs a resource bundle, and it makes two things harder: measuring contrast in a test

and generating a brand theme from a hex value. Tokens defined in code can be tested and read in a diff.

The payoff came with the contrast fix: white labels on the dark-mode accent (#60A5FA) measured about

2.5:1, below WCAG AA. Changing the dark value of Foreground.onAccent to gray900 fixed every filled

button at once (commit 896bc90).

Each semantic token carries a light, a dark and a high-contrast value. Resolution is a pure function,

resolved(for:highContrast:). The view modifiers .dsForegroundColor(_:) and .dsBackgroundColor(_:)

read colorScheme and colorSchemeContrast from the environment. Keeping the resolution pure means a

test can measure contrast in every scheme without building a view hierarchy.

Luminance through `Color.resolve(in:)`

The February code computed luminance with UIColor/NSColor, which did not work on watchOS. 2.0

uses Color.resolve(in:), which returns linear RGB, so the WCAG formula applies directly on every

platform. That API is one reason the minimum moved to iOS 17. The Observation framework is the other.

Observable managers that can be injected

ThemeManager and DSToastManager are @MainActor @Observable classes. They keep a shared

instance for convenience, because DSApp uses it, and they now also have public initializers. You can

pass your own instance to DSThemeProvider(themeManager:) or DSToastContainer(manager:). The tests

create their own instances for the same reason. The restored February code used ObservableObject;

2.0 switches both managers to @Observable, as the changelog notes.

Fingerprints instead of pixel snapshots

Every component group (buttons, badges, cards, selection, feedback) is rendered in light and dark

mode and compared with a stored reference. The comparison does not use pixels. RenderFingerprint

reduces an image to its size plus the average color of each cell in an 8×8 grid. The rendering test

allows 0.03 per color channel and 6 points of size difference. References are JSON files per platform

under Tests/RenderingReferences.

Exact pixel snapshots break on anti-aliasing differences between OS and Xcode versions, and a

snapshot library would add a dependency. A fingerprint still catches a wrong color, a missing

component or a layout shift. It can miss a small change that does not move any cell's average, and

that is the trade-off.

Where `ImageRenderer` is not enough

ImageRenderer draws switches as placeholders, ignores preferredColorScheme and never focuses a view.

Three fixes depended on exactly those things. So HostedViewTests (macOS only) hosts the views in

AppKit, draws them in a real window and reads the pixels back. The suite is serialized because hosted

text fields compete for focus.

A deliberate boundary

The built-in components read the color tokens, not the active theme. Presets and brand themes restyle

your own views through @Environment(\.dsTheme), but not DSButton or DSCard. The README states

this, and it is the first item on the roadmap. Wiring every component to the theme would have changed

every component's API and tests. The goal of 2.0 was to bring back working, tested code first.

What went wrong: the toast timer

DSToastManager.show starts a timer for each toast. The restored code had three problems with it,

and the test for it failed twice before it worked.

The first problem was a race. A toast replaced under the same ID, or dismissed early, left its old

timer running, and that timer then dismissed the new toast. The second was a crash. The duration was

converted with UInt64(toast.duration * 1_000_000_000), which traps for .infinity and for very large

values (commit 9d51b5e). The fix keeps timers in a dictionary keyed by ID, cancels the old one on

show or dismiss, checks Task.isCancelled after waking up, and never starts a timer for a duration

that is not finite:

swift
1guard toast.duration > 0, toast.duration.isFinite else { return }
2let id = toast.id
3let delay = Duration.seconds(min(toast.duration, Self.maximumDuration))
4dismissTimers[id] = Task { [weak self] in
5 try? await Task.sleep(for: delay)
6 // A cancelled timer belongs to a replaced or dismissed toast.
7 guard !Task.isCancelled else { return }
8 self?.dismiss(id: id)
9}

maximumDuration is 24 hours. A parameterized test covers .infinity, 1e300 and .nan.

The third problem was the test itself. It first slept for 400 ms and failed on a loaded iOS

simulator. Commit 2ff8316 replaced the sleep with polling for up to five seconds. Then, in pull

request #21, the test failed again on the GitHub macos-26 runner (Xcode 26.6). According to the

commit message of 97d7d3b, the toast was still on screen 7.04 seconds after the test started. The

timer task and the polling loop both run on the main actor, and the rendering and hosted-view tests

running in parallel kept the main actor busy for several seconds. Any wall-clock deadline was a guess.

The final version waits for the event itself:

swift
1@Test(.timeLimit(.minutes(1)))
2func autoDismissesAfterDuration() async throws {
3 let manager = DSToastManager()
4 manager.show(DSToastData(id: "short", title: "Bye", duration: 0.05))
5 #expect(manager.toasts.count == 1)
6 
7 // Await the timer itself, not a wall-clock deadline: while other tests
8 // keep the main actor busy, the timer can run seconds after 50 ms.
9 let timer = try #require(manager.dismissTimers["short"])
10 await timer.value
11 #expect(manager.toasts.isEmpty)
12}

dismissTimers became private(set) so the test module can read it, and the time limit stops the

test if the timer never finishes. Removing the dismiss call from the timer still fails the test. The

lesson: in an async test, await the thing you are waiting for, not the clock.

A similar lesson came from the theme provider. The February DSThemeProvider always applied

preferredColorScheme(themeManager.currentTheme.colorScheme), so an app that followed the system

never switched to dark mode. The fix forces a scheme only for an explicitly chosen theme

(followSystem ? nil : currentTheme.colorScheme). The first regression test only called that helper,

so a view that stopped using it would still pass. Commit e3443b2 added a hosted test instead: it

switches the window from .aqua to .darkAqua and checks that the child view sees the dark scheme.

Testing the helper and testing the view's behavior turned out to be different things.

Usage

Add the package with Swift Package Manager (from: "2.0.0"), import DesignSystemPro, and wrap your

root view in DSApp { ContentView() } to install the theme provider and the toast overlay. This is

the README quick start; like the other Swift snippets in the README (except the Package.swift

excerpt), it is compiled and run by Tests/DesignSystemProTests/ReadmeExamples.swift:

swift
1import DesignSystemPro
2import SwiftUI
3 
4struct ContentView: View {
5 @State private var notificationsOn = true
6 
7 var body: some View {
8 VStack(spacing: SpacingTokens.Content.comfortable) {
9 Text("Hello, DesignSystemPro")
10 .dsTypography(TypographyTokens.Heading.h2)
11 
12 DSStatusBadge("Ready", variant: .success)
13 
14 DSToggle(isOn: $notificationsOn, label: "Notifications")
15 
16 DSButton("Continue", isFullWidth: true) {
17 notificationsOn.toggle()
18 }
19 }
20 .padding(SpacingTokens.Layout.screenEdge)
21 }
22}

ThemeManager.shared.setTheme(PresetThemes.ocean) switches to a preset, and

BrandConfiguration with WhiteLabelConfiguration generates a theme from brand colors.

ColorContrastUtility.contrastRatio(_:_:) and meetsWCAG_AA(_:_:) check your own color pairs. The

example app in Examples/DesignSystemProDemo runs on iOS and macOS and shows every component family

and a theme switcher. The DocC documentation is published on GitHub Pages.

At tag v2.0.0, swift test reports 52 tests in 11 suites, and the CI run on the release merge

commit reports the same count. CI also builds the package for iOS, macOS, tvOS, watchOS and visionOS.

Limits

  • Components do not read the active theme yet, so presets and brand themes only affect your own views.
  • Avatar initials on the lighter palette colors and the checkbox tick in dark mode are still below

WCAG AA. Both are on the roadmap.

  • The 1.x components (alert, modal, navigation bar, tab bar, chart, progress indicator) were not

brought back, because they did not build against the v2 tokens.

  • DSTextArea is not available on tvOS and watchOS, because it uses TextEditor.
  • The fingerprint test is coarse by design: a change too small to move a cell's average color passes.
  • There are no performance benchmarks. Benchmark tables in the old README were removed because no

benchmark produced them.

Source code: github.com/muhittincamdali/SwiftUI-Design-System-Pro

Tags

#swiftui#design-system#design-tokens#swift-testing#accessibility
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share