Blog

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.

  • SwiftUI
  • design tokens
  • theming
  • refactoring

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.

Moving colors into values without touching call sites

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, and highlightPlate are calculated by StreamTokens. 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.