Diagnosing a SwiftUI Color Scheme Bug on macOS
A SwiftUI macOS window stayed in the old color scheme until focus changed. Here is the evidence, the failed fixes, and what my first tests missed.

I was testing the Light, Dark, and Auto appearance picker in hora when the app split into two realities.
The Settings window changed immediately. The main calendar window stayed light. Closing Settings, or making the main window key again, finally made the calendar catch up.
My first conclusion was simple: I had found a SwiftUI .preferredColorScheme bug on macOS.
That conclusion was too confident.
I had a repeatable symptom, but several appearance systems met inside the same view hierarchy: @AppStorage, SwiftUI environment values, AppKit semantic colors, a visual-effect view, and two separate scenes. The first version of this post treated one suspect as a proven cause. The follow-up investigation showed that the useful question was not "is this a SwiftUI bug?" but "at which layer does the new appearance stop becoming visible?"
This is the corrected debugging story.
The smallest version of the setup
hora stored one enum in UserDefaults through @AppStorage:
enum AppearanceMode: String, CaseIterable {
case auto = "Auto"
case light = "Light"
case dark = "Dark"
var colorScheme: ColorScheme? {
switch self {
case .auto: return nil
case .light: return .light
case .dark: return .dark
}
}
}Both the main scene and Settings read the same key and applied the preference to their own view hierarchy:
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .auto
var body: some View {
CalendarRootView()
.preferredColorScheme(appearanceMode.colorScheme)
}On macOS, a SwiftUI Settings scene is presented in its own window. That matters because .preferredColorScheme(_:) is a preference for a presentation, not a global command that paints every window in the process.
Apple's ColorScheme documentation says SwiftUI updates the environment value and redraws views that depend on it when the appearance changes. That describes the intended contract. It does not tell you which layer is stale when one composed window still shows old pixels.
What I could actually prove
The UI test performed the same sequence each time:
- Open Settings while macOS is using Dark appearance.
- Select Light and wait for the app to settle.
- Select Auto, which should resolve back to Dark.
- Capture the whole application every 200 milliseconds for three seconds.
- Close Settings and capture another sequence.
The images showed a consistent result. Settings reflected the selection, while the main window remained in its previous appearance until the focus relationship changed.
That proved a visual update was delayed in hora's real two-window composition. It did not prove that .preferredColorScheme alone was broken.
I also wrote a timing test that stored a raw string in UserDefaults and read it back immediately. It completed in well under a millisecond on my machine. I originally described this as proof that @AppStorage propagation was instant.
It was not.
The test only proved that a synchronous write could be read from the same UserDefaults object. It did not observe a second SwiftUI scene, wait for an @AppStorage update, inspect the environment, or verify a rendered pixel. The number looked precise, but it measured the easiest part of the path.
Appearance has more than one layer
A useful mental model is to separate the update into four questions:
| Layer | Question | How to inspect it |
|---|---|---|
| Stored value | Did the picker write Auto, Light, or Dark? | Read the raw UserDefaults value |
| SwiftUI environment | Did the destination hierarchy receive a new colorScheme? | Render the environment value in a temporary diagnostic label |
| Adaptive resources | Did backgrounds, separators, and materials resolve again? | Compare one native SwiftUI style with each bridged AppKit color |
| Window composition | Did the non-key window redraw the affected surfaces? | Capture both windows before and after focus changes |

My first test covered the top row. The bug lived somewhere below it.
That distinction matters whenever SwiftUI and AppKit share a window. AppKit describes appearance as an inherited value: apps, windows, and views can each receive it from an ancestor or override it. Its NSAppearance documentation also recommends adaptive colors and images instead of manually resolving everything against a momentary appearance.
The fixes I tried before understanding the boundary
Applying NSAppearance to every window
I tried setting NSApp.appearance, then assigning the same appearance to every NSWindow:
private func applyAppearance(_ appearance: NSAppearance?) {
NSApp.appearance = appearance
for window in NSApp.windows {
window.appearance = appearance
window.displayIfNeeded()
}
}This made some AppKit-managed surfaces respond, but it also gave two systems ownership of the same decision. SwiftUI still had .preferredColorScheme, while AppKit received an explicit appearance. The result was not a clean fix. I saw mixed surfaces and occasional flashes during transitions.
Forcing another display pass
display(), displayIfNeeded(), and invalidateShadow() sounded promising because the visible symptom looked like a stale window.
They did not make the full SwiftUI hierarchy recompute its environment-dependent content. Asking AppKit to draw again is not the same as changing the inputs SwiftUI used to build the view tree.
Observing UserDefaults.didChangeNotification
The notification fired. That was useful evidence that a write occurred, but it still did not identify whether the stale layer was the environment, a semantic color, a material, or the final window composition.
Removing AppKit appearance overrides
This reduced the number of moving parts, which was useful. It did not eliminate the symptom by itself. That was another sign that there was no single line to blame.
A better diagnostic harness
If I had to investigate this again, I would build the harness before trying more fixes.
I would put these elements in both scenes:
struct AppearanceProbe: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(alignment: .leading) {
Text("Environment: \(String(describing: colorScheme))")
Rectangle().fill(.windowBackground).frame(height: 24)
Rectangle()
.fill(Color(nsColor: .windowBackgroundColor))
.frame(height: 24)
}
}
}Then I would test four transitions with Settings open:
- Auto to Light
- Light to Dark
- Dark to Auto
- A system appearance change while Auto is selected
The temporary environment label answers whether SwiftUI received the new value. The two rectangles reveal whether different resource paths resolve at the same time. Repeating the matrix with and without the visual-effect bridge isolates the material layer. Only then is it worth changing window-level AppKit state.
This approach is less exciting than finding a framework bug. It is also much more likely to produce evidence somebody else can use.
What finally changed
The fix landed two days later as a group of changes:
- Auto stopped passing
nilthrough the same transition path as explicit Light and Dark modes. hora now resolves the current AppKit appearance to an explicit SwiftUIColorScheme. - A forced active state was removed from the sidebar's
NSVisualEffectViewbridge. - Several
Color(NSColor.*)constructions in the calendar surfaces were replaced with native SwiftUI shape styles. - Focus changes caused by Settings stopped triggering unrelated synchronization work while I was inspecting appearance updates.
Together, those changes made both windows update correctly in hora. They do not establish a universal rule that every optional color scheme, AppKit color bridge, or visual-effect view is broken.
The full postmortem is in How I Fixed SwiftUI Appearance Switching on macOS.
The lesson I kept
The hardest debugging mistake here was not the code. It was promoting a plausible explanation to a conclusion because the symptom matched it.
A precise timing number did not validate the rendering path. A stale non-key window did not prove which framework owned the stale state. A workaround that fixed hora did not become a general law of SwiftUI.
What helped was separating state, environment, adaptive resources, and window composition, then removing one boundary at a time.
Appearance switching is now part of the native Mac experience in hora Calendar 1.0. If you use Google Calendar and want to see the result, try hora Calendar on the Mac App Store.
SwiftUI appearance debugging FAQ
Does preferredColorScheme work on macOS?
Yes. preferredColorScheme is the supported SwiftUI API for requesting Light or Dark appearance for a presentation. A stale surface can still come from the surrounding composition, including separate scenes, bridged AppKit views, visual effects, or resources that did not resolve again.
What does nil mean in preferredColorScheme?
Passing nil removes an explicit Light or Dark preference and lets the presentation follow its inherited appearance. In hora, transitions back to nil were part of the failing path, so the app now resolves Auto to an explicit scheme as a local workaround.
How do you test AppStorage across SwiftUI scenes?
Do not treat a direct UserDefaults write and read as an AppStorage propagation test. Observe the value in both live scenes, expose the destination colorScheme environment during diagnostics, and verify the rendered result separately.
How should I debug mixed SwiftUI and AppKit appearance code?
Start with one owner for the appearance preference. Then inspect the stored value, SwiftUI environment, adaptive colors or materials, and final window redraw as separate layers. Add AppKit overrides only when you can identify the boundary that requires them.


