Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Troubleshooting

Troubleshooting ColorTokensKit

Fix the problems people hit most, from colors that ignore dark mode to hex strings that vanish.

Updated View as Markdown

Each entry is named after what you notice, then says why and what to do. If yours isn't here, open an issue on GitHub.

"Value of type 'ProTheme' has no member 'foregroundPrimary'"#

The tokens live in ColorTokens.swift, a file you copy into your app, not in the package. Add it to your app target, as Installing ColorTokensKit shows.

"'ProColor' is deprecated: renamed to 'ProTheme'"#

ProColor is the old name of ProTheme. It stays as a deprecated alias, so older code still compiles, with this warning wherever it says ProColor:

swift
let brand: ProColor = Color.proBlue   // 'ProColor' is deprecated: renamed to 'ProTheme'

Change the name and nothing else:

swift
let brand: ProTheme = Color.proBlue

Xcode offers a fix-it on each warning that makes this change. For a whole project, replace ProColor with ProTheme everywhere, including your copy of ColorTokens.swift, which extends it. The alias is a typealias, so a ProColor and a ProTheme are one type: you can pass either where the other is asked for, and only the warning differs. A brand family built as ProColor(oklch: OKLCHColor(hex:)) is now one call, ProTheme(hex:).

A color doesn't change in dark mode#

A stop, such as _100, is one fixed color in both modes. A semantic token moves to another stop in dark mode, so draw with tokens:

swift
VStack(alignment: .leading, spacing: 4) {
    Text("Lisbon")
        .font(.headline)
        .foregroundStyle(brand.foregroundPrimary)     // _1000 in light mode, _50 in dark
    Text("3 nights from October 12")
        .font(.subheadline)
        .foregroundStyle(brand.foregroundSecondary)   // _800 in light mode, _200 in dark
}
.padding()
.background(brand.backgroundPrimary)                  // _50 in light mode, _1000 in dark
light mode
Lisbon3 nights from October 12

For a pair of stops of your own, add a token.

Converting a token gives its light-mode value#

Conversions such as toOKLCH() and getHexString() read a Color in SwiftUI's default environment, which is light mode on iOS 17, macOS 14 and later. For dark mode, convert the stop the token uses there: foregroundSecondary is _200 in dark mode, so use brand._200.toOKLCH(). For the current appearance, resolve the token with SwiftUI's Color.resolve(in:).

A UIColor or CGColor doesn't update when the appearance changes#

In our tests on iOS 17 and 18.0, UIColor(_:) kept only the light value of a token, and even of Apple's Color.primary. Build the UIColor from the token's two stops instead. UIKit takes each stop as a Color, which is what toColor() is for:

swift
let secondaryText = UIColor(   // foregroundSecondary
    light: UIColor(brand._800.toColor()),
    dark: UIColor(brand._200.toColor())
)

A CGColor holds one resolved value, so set layer colors again when the appearance changes. UIKit and AppKit show both fixes.

Colors look different after updating#

The palette changes between releases on purpose, so a screen can shift even where your code didn't. The rules that decide contrast hold: each stop keeps its lightness, and every token pair keeps its contrast. What can move:

  • Chroma. Colors can get more vivid, most in the middle stops, around _350. Where one now looks too strong for its job, desaturate(by:) takes chroma back out at the same lightness.
  • Names. Each named theme sits on a hue that's a multiple of 10°, and older names moved 5° or less to land there.
  • Themes. Coral, amber, mustard, chartreuse, emerald, turquoise, cerulean, azure, grape, orchid and rose fill the gaps between the older names.

From a much older version the change is bigger: every hue now shares one lightness per stop, and some names moved to the hue they describe, so proBlue, once a cyan, is blue.

Snapshot and screenshot tests fail when colors change, so re-record their reference images once the screens look right. WCAG contrast checks keep passing. If nothing changed at all, Xcode is still resolving the old package: see Xcode keeps an older version.

A saved hue number doesn't match a named theme#

primary(forHue:) builds its ramp at exactly the hue you pass, and each named theme sits on a multiple of 10°. A hue saved from an older version, such as 251° for blue, gives a theme that looks like the named one but doesn't equal it:

swift
let saved = ProTheme.primary(forHue: 251)   // blue's hue in an older version
let isBlue = saved == Color.proBlue          // false: proBlue is at 250°

Save a named theme by its name instead, and look it up with Color.allProHues["Blue"].

ColorPalettes.json or ColorRampLoader is missing#

The package no longer ships a resource bundle. Every ramp is built in code, the gray one included, so there's no JSON file to load. Use a ready-made theme such as Color.proBlue, or build one for any hue with ProTheme.primary(forHue:).

Colors look duller on an external monitor#

The palette uses Display P3, the gamut of every iPhone since the iPhone 7, and 301 of its 720 colors outside the grays are beyond sRGB. Many external monitors show only sRGB, and the system clips those colors there, so they look less vivid in your app, the Simulator or a screenshot.

There's no sRGB fallback. Judge color on an iPhone or another Display P3 screen. Fitting colors into Display P3 explains the gamut.

Colors look less vivid than expected#

We chose this. At each stop, every hue gets the same chroma, so any family can stand in for any other, and hues that could go further, such as reds and pinks, are held back to match. On an external monitor, first check whether it shows Display P3.

When one color needs more, call saturate(by:). It adds chroma at the same lightness, so contrast doesn't change. Color.proPink.foregroundTertiary.saturate(by: 1) doubles pink's chroma in light mode, and goes as far as Display P3 allows in dark.

What does equal lightness cost? has the numbers for every hue.

Yellow looks olive at darker stops#

Every hue has the same lightness at each stop, so yellow at _600 is as dark as blue at _600, and from there down it reads as olive. For a warm dark color, use proGold:

#837509
proYellow._600
#956d09
proGold._600

saturate(by:) can't brighten it much: at _600, yellow already sits near the edge of Display P3. How the ramps are built explains the trade-off.

A hue number gives the wrong color#

primary(forHue:) and the ramp generator read OKLCH hues, which don't match the hue numbers of HSL or a design tool. Apple's system blue, #0088FF, is 208° in HSL, and 208° in OKLCH is a cyan. Start from the color instead: ProTheme(hex:) builds the family at the color's own hue.

swift
let fromHSL = ProTheme.primary(forHue: 208)   // #0088FF's HSL hue, a cyan in OKLCH
let brand = ProTheme(hex: "#0088FF")          // the color itself, at 254.09°
#11a8c0
primary(forHue: 208)
#0088ff
ProTheme(hex:)

Replacing your app colors turns a brand color into a family step by step.

A gray brand color gives a mustard family#

A gray has no hue, so the hue OKLCHColor(hex:) reads from one is a leftover of the math, and ProTheme.primary(forHue:) builds a full-color family from it: for #333333, a dark mustard yellow. ProTheme(hex:) gives any color with a chroma of 0.005 or less the gray family:

swift
let fromHue = ProTheme.primary(forHue: OKLCHColor(hex: "#333333").h)   // hue 89.88, a mustard
let brand = ProTheme(hex: "#333333")                                   // the gray family
#b8940d
primary(forHue:)
#333333
ProTheme(hex:)

brand has gray's 20 stops and tokens, and keeps #333333 as its own color.

A hex color comes out nearly invisible#

Color(hex:) reads 3, 6 or 8 hex digits, and any other length gives a nearly transparent color instead of an error. Eight digits are AARRGGBB, alpha first, not the RRGGBBAA order CSS uses, so move alpha to the front. Color(hex:) lists every hex initializer.

A hex string doesn't match the swatch#

This wiki writes palette colors as Display P3 hex, the way an iPhone shows them, and getHexString() returns sRGB hex. Inside sRGB, the two are one color written two ways: Color.proBlue._600 is #4176B8 here and 2C77BD from getHexString(). A stop beyond sRGB has no sRGB hex, so getHexString() clamps it into sRGB, and the result is duller:

#13b9b3
proTeal._400
#00bcb5
getHexString()

Name palette colors by family and stop rather than by hex.

An oklch or lch string gives a pink color#

The string parsers fall back to a default color when a string doesn't match: #E7729B for oklchString and #DF97AC for lchString.

#e7729b
oklchString fallback
#df97ac
lchString fallback

Write oklch(0.68 0.14 167) with three plain numbers: no percent signs, no alpha, and 0.68 rather than .68. Write lch(65% 51 166) with a percent sign on the lightness.

A gradient looks gray or pale in the middle#

In our measurements on iOS, SwiftUI's default gradient takes a straight path through OKLab, and between distant hues the middle of that path is gray. proGradient() goes around the OKLCH hue wheel instead, so the middle stays vivid:

swift
// Lisbon to Reykjavík
let colors = [Color.proOrange.foregroundTertiary, Color.proSky.foregroundTertiary]

VStack {
    LinearGradient(colors: colors, startPoint: .leading, endPoint: .trailing)
        .frame(width: 160, height: 64)
    colors.proGradient(from: .leading, to: .trailing)
        .frame(width: 160, height: 64)
}
light mode

How gradients blend and ease compares the paths.

See also

  • Color(hex:) Turn a hex value from your design file into a SwiftUI color, with or without transparency.
  • Changelog See what each release changed before you update, from the first release to the latest.
  • Managing dark mode Get dark mode without extra code: tokens switch stops for you, and your own colors can too.
  • .lighten(by:) Add highlights and lighter steps to any color, landing on an exact palette stop.