How I Fixed SwiftUI Appearance Switching on macOS
The real fix for a stale SwiftUI macOS window combined an explicit color scheme, adaptive styles, and a safer NSVisualEffectView bridge in hora.

Two days after publishing my appearance debugging notes, I fixed the problem.
The tempting version of this story is that somebody sent me a secret three-line SwiftUI workaround. That did not happen.
The main hora window started updating correctly only after I changed three boundaries at once:
- Auto mode now resolves to an explicit
ColorSchemeinstead of passingnilafter an explicit Light or Dark value. - The AppKit bridge for the sidebar stopped forcing its visual-effect view into the active state.
- Calendar surfaces stopped constructing several semantic colors through
Color(NSColor.*)and used SwiftUI shape styles instead.
One more change removed unrelated sync work triggered by Settings focus changes. That did not choose the correct color scheme, but it made the transition easier to reason about and stopped the render pipeline from doing unnecessary work at exactly the wrong moment.
The important qualification is that this is a postmortem for hora's view hierarchy. It is not proof that any one of those APIs is universally broken.
The final appearance owner
The original model returned an optional scheme:
var colorScheme: ColorScheme? {
switch self {
case .auto: return nil
case .light: return .light
case .dark: return .dark
}
}Passing nil is valid SwiftUI. It removes the explicit preference and lets the presentation follow the inherited appearance.
The failing transition in hora was more specific: the user could move from an explicit .light or .dark preference back to nil while Settings was the key window, and the main window would keep showing its previous state until another focus change.
Instead of asking that path to switch between explicit and inherited ownership, hora now resolves Auto before applying the preference:
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .auto
@State private var systemIsDark = NSApp.effectiveAppearance
.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
private var resolvedColorScheme: ColorScheme {
switch appearanceMode {
case .auto:
return systemIsDark ? .dark : .light
case .light:
return .light
case .dark:
return .dark
}
}
var body: some View {
CalendarRootView()
.preferredColorScheme(resolvedColorScheme)
}Apple describes effectiveAppearance as the appearance AppKit uses to draw the application interface. hora asks it for the best match between .darkAqua and .aqua, then gives SwiftUI an explicit Light or Dark value.
That makes the ownership simple: the picker owns the user's mode, the resolver interprets Auto, and .preferredColorScheme receives one concrete value.
How Auto mode refreshes today
Resolving Auto once at launch would create a different bug. If macOS changes appearance later, hora has to read it again.
The app currently rechecks the system appearance whenever it becomes active:
.onReceive(NotificationCenter.default.publisher(
for: NSApplication.didBecomeActiveNotification
)) { _ in
updateSystemAppearance()
}
private func updateSystemAppearance() {
let isDark = NSApp.effectiveAppearance
.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
if systemIsDark != isDark {
systemIsDark = isDark
}
}This is a pragmatic resolver, not a complete appearance observer. NSApplication.didChangeScreenParametersNotification is useful when the display configuration changes, but it is not a notification for a scheduled Light or Dark transition. If macOS changes appearance while hora remains active, this resolver may not refresh until the app activates again.
A more complete AppKit hook belongs on the view or window that owns the appearance. Apple provides viewDidChangeEffectiveAppearance() for that lifecycle. Semantic colors and materials should still inherit automatically wherever possible, instead of mirroring the system setting into extra state.
The visual-effect bridge was forcing the wrong state
The right sidebar used an NSViewRepresentable to find its surrounding NSVisualEffectView. The bridge set both the blending mode and this state:
visualEffectView.state = .activeApple documents NSVisualEffectView.State as the switch that controls whether the effect is active, inactive, or follows the window's active state. The default is .followsWindowActiveState.
Forcing .active was unnecessary for hora. It also meant the sidebar material no longer followed the same window activity lifecycle as the rest of the composition. Removing the override made that surface participate in the normal state transition again:
if let effectView = ancestor as? NSVisualEffectView {
effectView.blendingMode = .behindWindow
return
}I want to be precise here. Apple's documentation does not say that .active blocks all appearance propagation. What I can say is that the forced state was part of the stale sidebar path in hora, and removing it was necessary in the working build.
Semantic SwiftUI styles removed another stale-looking layer
The calendar views also contained constructions such as:
.background(Color(nsColor: .windowBackgroundColor))
.overlay(Rectangle().fill(Color(nsColor: .separatorColor)))Those colors looked correct on initial render. During the failing transition, some of the affected surfaces kept looking as if they had resolved under the previous appearance.
I replaced them with SwiftUI's semantic shape styles in the views involved in the bug:
.background(.windowBackground)
.overlay(Rectangle().fill(.separator))The goal was not to ban Color(nsColor:). AppKit colors are useful and can be adaptive. The goal was to keep the appearance-dependent surface inside one rendering system when SwiftUI already had the matching semantic style.
That reduced the number of points where I had to ask whether a color had been resolved by AppKit, bridged into SwiftUI, cached in a value, or rebuilt with the new environment.

The fourth change was about timing, not color
hora used NSApplication.didBecomeActiveNotification to trigger a calendar sync. Opening and closing Settings changes focus, so appearance testing could also start network work, model updates, and a calendar render.
The fix skipped that activation sync while the Settings window was visible:
let hasSettingsWindow = NSApp.windows.contains { window in
window.title == "Settings"
|| window.identifier?.rawValue.contains("Settings") == true
}
if hasSettingsWindow { return }This was not the root cause of the appearance mismatch. Removing unrelated work simply made the transition deterministic and stopped a focus change from producing a sync storm.
It was a good reminder that performance noise can disguise a state bug. If clicking one control also starts networking, persistence, and a large view update, the screenshot tells you less than you think.
What the original tests did and did not prove
The timelapse UI test was useful. It preserved the exact symptom and showed that closing Settings changed the result.
The unit test was much weaker. It wrote and read a UserDefaults string, then called that @AppStorage propagation. It did not test a second scene or the SwiftUI environment.
After the code change, I repeated the transition matrix with both windows visible:
| Transition | Expected result |
|---|---|
| Auto to Light | Both windows become light |
| Light to Dark | Both windows become dark |
| Dark to Auto on a light system | Both windows become light |
| Light to Auto on a dark system | Both windows become dark |
The working result was consistent, but the temporary diagnostic suite was not a perfect automated pixel regression test and was later removed during the test restructure. I do not want to claim stronger coverage than the repository has today.
A better permanent test would mount two scene-like hierarchies, expose the colorScheme environment from both, and pair that state assertion with a targeted snapshot of the affected semantic surfaces.
Why the combined fix worked for hora
The change made each layer's responsibility clearer:
AppearanceModestores user intent.- The resolver maps Auto to AppKit's current effective appearance.
- SwiftUI receives an explicit scheme for the presentation.
- The visual-effect view follows normal window activity.
- SwiftUI semantic styles resolve inside the same environment as the rest of the view.
- Settings focus changes do not start unrelated sync work.
None of these is a heroic trick. Together they removed the ambiguity that made one stale window look like a single framework defect.
Months later, the explicit resolver is still in hora's current source. That is the strongest evidence I have that the workaround belongs in this codebase.
I have since applied the same discipline to another appearance problem: user-selected calendar colors that became unreadable in light mode. That post is How I Fixed SwiftUI Color Contrast with OKLCH and APCA.
If your schedule lives in Google Calendar and you want a native Mac client where these details are part of the product, try hora Calendar on the Mac App Store.
SwiftUI appearance switching FAQ
04 questions / quick answers


