August 15, 2026 · 9 min read
SwiftUI Design Tokens at Runtime: A Facade for 367 Call Sites
Forty-four static color constants were read from 91 files at 367 call sites, so themes could not be swapped at runtime. Moving values behind computed properties without touching a single caller.
There were 44 color tokens. They were declared as static let and called 367 times in 91 files.
To sell paid themes, these values must change while the app is running. static let cannot change.
I could edit 367 places. While considering that, I found other things. A full survey of seven gradients found three unreferenced pieces of dead code, followed by 25 dead tokens.
The first part is how I changed constants to runtime values without touching callers; the second is how I reduced 44 tokens to 41.
I separated a value type and a facade
I moved static let into a value type and kept the old names as computed properties.
// 전: 컴파일타임 상수
enum StreamTokens {
static let paper = Color(hex: 0xFFFFFF)
static let ink = Color(hex: 0x121212)
}
// 후: 값 타입 + 파사드
struct StreamPalette {
let paper: Color
let ink: Color
}
enum StreamTokens {
private(set) static var active = StreamPalette.default
static var paper: Color { active.paper } // 계산 프로퍼티
static var ink: Color { active.ink }
static func activate(_ palette: StreamPalette) { active = palette }
}
Callers remain unchanged. Code that said StreamTokens.paper has the same syntax and meaning.
Access cost is the same too: one struct-property read, not a dictionary lookup or string-key search.
The one thing I preserved was not editing 367 places. A large replacement creates missed sites and makes review impossible.
The facade solves two things at once
| Problem | What the facade does |
|---|---|
| 367 call sites | Keeps the names unchanged |
| Runtime switching | Changes active with activate(_:) |
The value type exists so I can treat a palette as a value: create, validate, and compare it. static let cannot do that.
I did not use a dictionary
I could have put roles in a dictionary and written palette["paper"]. The comment above the declaration explains why I did not.
The contract of this type is that every role is listed as a stored property. A dictionary compiles when a new palette omits a role; memberwise init rejects it at compile time. A palette is valid only when all 39 are written.
—
StreamPalette.swift:11-13
A dictionary compiles when a key is missing; it appears only when a screen uses that color.
Stored properties force memberwise initialization. Omitting a role from a new palette becomes a compile error.
Changing a palette identifier is a migration
// JustSendKit/Sources/Design/StreamPalette.swift:21-22
/// 팔레트 신원. `AppSettings`에 rawValue로 저장되므로 **문자열을 바꾸면 마이그레이션**이다.
public enum ID: String, Codable, CaseIterable, Sendable {
The user's selected palette is stored as a string. Rename case craftLight and a user's setting points nowhere.
The comment is immediately above the declaration, where anyone attempting a rename will pass.
I kept derived values out of the palette
Some tokens derive from others. A pressed surface inside a card is the ground color made one step darker.
I do not store it in the palette; I calculate it.
// JustSendKit/Sources/Design/ColorMixing.swift:51-54
/// 방향을 팔레트가 정하지 않아도 위계가 늘 같은 방향으로 쌓인다.
public func steppedSurface(_ amount: Double) -> Color {
mixed(with: prefersDarkInk ? .black : .white, by: amount)
}
Callers look like this.
// JustSendKit/Sources/Design/StreamPalette.swift:631, 676
sunken: paper.steppedSurface(0.05),
selectionFill: paper.steppedSurface(0.22))
Light palettes mix black and dark palettes mix white. The hierarchy builds in the same direction without the palette choosing the direction.
The comment also records the problem with storing derived values.
Derived values do not belong here. Values from other roles such as
controlFill,swipeTrackSweep, andhighlightPlateare calculated byStreamTokens. Putting derived values in a palette allows mismatched palettes and forces each palette to be checked again for promises such as “the highlight color equals the home-card color.”—
StreamPalette.swift:15-18
When a promise is stored as values, each palette must be checked. When it is calculated, the promise exists once in code.
It is notable that the dead swipeTrackSweep remains as an example in the comment. It is in the derived list but has zero references.
A full gradient survey found half were dead
While moving tokens I also wanted to clean up gradients, to simplify the visual impression.
There were seven gradients across the app. I searched for each reference.
| Gradient | References |
|---|---|
swipeTrackSweep |
0 outside declaration |
swipeMarkHalo |
0 outside declaration |
heroScrim |
0 outside declaration |
| Other four | In use |
Three were dead.
The cause was feature removal. The swipe-track feature was removed entirely, but its tokens remained.
Twenty-five dead tokens came along
When I deleted the three dead gradients, the color tokens they referenced were unused too.
There were 23 tokens beginning swipe*, plus heroShape and radiusHero: 25 in all.
The count immediately after deletion was this.
| Before | After | |
|---|---|---|
StreamTokens lines |
1,102 | 979 |
That count is immediately after the work: 123 lines disappeared. With 12 palettes now, it is 1,128 lines.
When removing a feature, it is hard to remove its tokens at the same time. Tokens may be used elsewhere, so they must be checked; if checking is tedious, they remain. Then the next person assumes they are used.
Dead code obstructs new work
Dead tokens still compile. The problem appears when creating a new palette.
A palette requires choosing 44 colors. If 25 are dead, I am answering 25 questions for a feature that no longer exists.
Removing one decoration reduced the palette to 41
The Home destination card had a dimensional glyph: a top-left light, bottom-right shade, and cast shadow.
It had three layers and was the place on that card that needed to recede most.
On the same card I reduced the title from 15pt to 13pt. The number below is the subject. I had put three layers on the mark that should recede.
I changed it to solid faint. Three palette tokens died.
| Dead token | What it was |
|---|---|
iconVolumeTop |
Glyph highlight color |
iconVolumeBottom |
Glyph shade color |
iconShadow |
Glyph shadow color |
The count became 41 from 44. The current source has 39 stored properties; two more died afterward.
There was a side effect. Creating a new palette no longer requires deciding the “dimensional glyph highlight color,” a difficult question. Creating 12 palettes would otherwise require answering it 12 times.
Reducing tokens is not reducing colors; it is reducing decisions.
I classified the remaining gradients as function or decoration
Four remained, and I had to decide whether to delete them.
The criterion is one: does the function remain if it becomes solid color?
| Gradient | Decision | Basis |
|---|---|---|
| Scroll fade ramp | Keep | Moves content behind the bar; solid cannot replace it |
| Capture-stage darkness | Keep | Blocks background content for readability; comment has light-mode measurement |
| Destination glyph depth | Remove | Decoration |
| Capture-stage slot glow | Decision pending | Signature motion area; product identity decision needed |
The first two must be gradients. Scroll fade naturally disappears behind the bar; a solid overlay creates a boundary.
I deferred the fourth. The capture stage contains the app's signature motion, so deciding what to remove is product identity, not a technical decision.
I created and deleted a dead cache myself
During this work I created code to cache derived gradients, an optimization to avoid recalculating them.
Then I found every gradient that cache served was dead code. Its reason to exist disappeared.
I created and deleted it in the same work. The order—optimize first, check usage later—was wrong.
After the move, checks are needed per palette
Moving colors into values creates new mistakes: one palette can have insufficient contrast even when another is fine.
So I made tests for conditions each palette must satisfy.
| Check | What |
|---|---|
| Title contrast | 7 or more |
| Body contrast | 3.5 or more |
| Marker contrast | 2 or more |
| Fill contrast | 3 or more |
| Capsule contrast | 1.1 or more |
| Surface ladder | Ground/plate contrast at least 1.05 |
| Dark-channel delta | 0.05 or more |
With static let, these checks were unnecessary: one set of values could be checked by eye.
Once multiple sets exist, each needs checking. That is the cost of freedom.
The surface ladder fails most often
The ground-to-plate contrast of 1.05 is particularly difficult. It is the condition that a card must differ from its background.
1.05 is low: the minimum at which a person sees “different.” Yet palettes often fell below it.
Choosing background and card from one family naturally lowers contrast. A quiet impression brings the colors close, and when close, the card disappears.
Counting it meant choosing 468 colors by hand
I counted how much value moved after the refactor.
| Value | |
|---|---|
StreamPalette.swift |
967 lines |
| Stored properties | 39 |
| Palettes | 12 |
StreamTokens.swift |
1,128 lines |
Twelve palettes each have 39 values: effectively choosing 468 colors by hand. That is why removing tokens was more than cleanup. Remove one role and I do not answer it 12 times.
The comment on the ID enum records the source of each palette.
// StreamPalette.swift:22-60
public enum ID: String, Codable, CaseIterable, Sendable {
case craftLight // 기본 · 밝음
case craftDark // 기본 · 어둠
// MARK: 유료 · 밝은 지면
case blushPaper // Structured
case rosePineDawn // Rosé Pine Dawn
case catppuccinLatte // Catppuccin Latte
case amberLinen // Gentler Streak
case apricotPlum // Tangerine
// MARK: 유료 · 어두운 지면
case nord, gruvbox, kanagawa, everforest, obsidian
}
Ten of the 12 are paid; five have light surfaces and five dark surfaces. Names preserve their sources. Nord, Gruvbox, Kanagawa, and Everforest were made for code-reading screens; the comment explains why those dark surfaces fit our work.
rawValue is stored in user settings, so changing the string is a migration. The first line above the enum records that fact.