Table of Contents
- The problem
- Design decisions and alternatives
- Two layers of color tokens
- Luminance through `Color.resolve(in:)`
- Observable managers that can be injected
- Fingerprints instead of pixel snapshots
- Where `ImageRenderer` is not enough
- A deliberate boundary
- What went wrong: the toast timer
- Usage
- Limits
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:
1guard toast.duration > 0, toast.duration.isFinite else { return }2let id = toast.id3let delay = Duration.seconds(min(toast.duration, Self.maximumDuration))4dismissTimers[id] = Task { [weak self] in5 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:
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 tests8 // keep the main actor busy, the timer can run seconds after 50 ms.9 let timer = try #require(manager.dismissTimers["short"])10 await timer.value11 #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:
1import DesignSystemPro2import SwiftUI3 4struct ContentView: View {5 @State private var notificationsOn = true6 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.
DSTextAreais not available on tvOS and watchOS, because it usesTextEditor.- 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
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

