Theming
The nook’s chrome - top bar, compact pill, Settings - is painted from a
NookResolvedTheme. The framework resolves one on every layout pass from the
current AppState, hands it to your views through the
\.nookResolvedTheme environment value, and persists user-facing appearance
choices to UserDefaults between launches.
You can leave all of this alone (the default looks like the demo), tint chrome
labels with a host-supplied palette, or swap surface materials and the chrome
palette through NookAppearancePreferences.
When to use which knob
- Custom palette colors. Set
NookConfiguration.themeto a closure that returns your ownNookResolvedTheme. This is the right level for a host product that wants a distinct chrome tint. - Light/dark/follow-system, solid/translucent surface, notch-fused vs
free-floating. These are user-facing preferences. They live on
AppState.appearancePreferencesand ship with a Settings UI - usually you don’t override them; you just read the resolved values back. - Just read theme colors in your view. Pull
@Environment(\.nookResolvedTheme)and use the named slots. Don’t reach forColor.primaryorColor.secondaryon the notch panel - see the pitfall below.
The resolved theme
NookResolvedTheme is a flat Sendable struct of named color slots. Every
chrome view reads from these slots, so a host palette only needs to fill them
in once:
public struct NookResolvedTheme: Sendable { public var primaryLabel: Color public var secondaryLabel: Color public var tertiaryLabel: Color public var quaternaryLabel: Color public var subtleFill: Color public var subtleStroke: Color public var headerInactiveIcon: Color}Read it in a view with the SwiftUI environment:
struct MyHomeView: View { @Environment(\.nookResolvedTheme) private var theme
var body: some View { VStack(spacing: 6) { Image(systemName: "sparkles") .foregroundStyle(theme.secondaryLabel) Text("Hello") .foregroundStyle(theme.primaryLabel) } }}The default resolver, NookResolvedTheme.live(appState:), derives the palette
from the user’s appearance preferences, the application’s effective
appearance, and macOS’s Reduce Transparency setting. Replace it with your own
closure on NookConfiguration.theme:
var configuration = NookConfiguration()configuration.theme = { appState in MyPalette.resolve(appState) }NookApp.main(configuration)A complete host palette
Build your colors explicitly - black or white at a fixed opacity - rather than
from system-adaptive colors like Color.primary. This is the most common
pitfall on the notch panel; see Use explicit colors, not adaptive
ones below.
import NookAppimport SwiftUI
enum SunsetTheme { @MainActor static func resolve(_ appState: AppState) -> NookResolvedTheme { NookResolvedTheme( primaryLabel: Color(red: 1.0, green: 0.93, blue: 0.86), secondaryLabel: Color(red: 1.0, green: 0.78, blue: 0.62).opacity(0.85), tertiaryLabel: Color(red: 1.0, green: 0.66, blue: 0.50).opacity(0.70), quaternaryLabel: Color(red: 1.0, green: 0.62, blue: 0.46).opacity(0.50), subtleFill: Color.white.opacity(0.08), subtleStroke: Color.white.opacity(0.16), headerInactiveIcon: Color(red: 1.0, green: 0.70, blue: 0.55).opacity(0.55) ) }}
var configuration = NookConfiguration()configuration.setHome { ThemedHomeView() }configuration.theme = { SunsetTheme.resolve($0) }NookApp.main(configuration)The closure is @Sendable @MainActor (AppState) -> NookResolvedTheme, so it
runs on the main actor during view rendering and is free to touch main-actor
state on AppState. Resolving against AppState lets the palette react to
user preferences if you want it to - for example, picking different tints for
light vs dark chrome.
The full working example is at Examples/ThemedNook/main.swift.
User-facing appearance preferences
NookAppearancePreferences carries the user-configurable surface and chrome
state. The framework owns the Settings panel that writes it; your code reads
from it.
public struct NookAppearancePreferences: Equatable, Codable, Sendable { public var chromePalette: NookChromePalette // .followSystem / .dark / .light public var surfaceStyle: NookSurfaceStyle // .solid / .translucent public var presentation: NookPresentation // .auto / fused / free-floating public var hapticFeedbackEnabled: Bool public var keepNookOpen: Bool}chromePalettepins the chrome to dark / light or follows macOS.surfaceStylepicks a solid panel that matches the menu-bar notch (the default), or a translucent material that lets the wallpaper through.presentationis.autoby default and is the knob that makes the chrome work on a Mac with no notch.
A host that just wants to read the user’s current choices can do so directly:
configuration.theme = { appState in switch appState.appearancePreferences.surfaceStyle { case .solid: return SolidPalette.resolve(appState) case .translucent: return FrostPalette.resolve(appState) }}If you need to write preferences programmatically (rare - the Settings UI is
usually enough), go through AppState.replaceAppearancePreferences(_:) so
the change is persisted:
var prefs = appState.appearancePreferencesprefs.chromePalette = .darkappState.replaceAppearancePreferences(prefs)Direct assignment to appState.appearancePreferences updates the in-memory
state but skips persistence - the next launch will start from whatever was
last persisted.
Persistence
Appearance preferences are encoded as JSON and stored in
UserDefaults.standard under the key opennook.appearance.v1.
AppState.init loads from there on launch; replaceAppearancePreferences
writes back through NookAppearanceStore.save. Failed encode/decodes fall
back to defaults silently rather than wiping the record - decoding is
forward-compatible too, so a JSON record from an older build that is missing
later-added fields still round-trips correctly.
You don’t need to do anything to get persistence - it is on by default for every host that uses the built-in Settings UI.
Pitfalls
Use explicit colors, not adaptive ones
Color.primary, Color.secondary, and the SwiftUI semantic colors are
system-adaptive: they read the current colorScheme and resolve light or
dark accordingly. The nook lives on a non-activating panel whose SwiftUI
colorScheme is unreliable, so an adaptive color can resolve for the wrong
appearance - white text rendering on a white light-mode panel, for example.
Resolve the appearance once when building your NookResolvedTheme and emit
concrete Color.white.opacity(...) / Color.black.opacity(...) values. The
framework’s own palette does exactly this; see NookResolvedTheme.resolve in
Sources/NookKit/App/NookResolvedTheme.swift for the reference implementation.
Don’t write appearancePreferences directly
Assigning to appState.appearancePreferences looks like it works - the
chrome updates - but the change is not persisted, so it vanishes on the next
launch. Always go through replaceAppearancePreferences(_:).
Reduce Transparency
The default resolver bumps subtleFill slightly when the user has Reduce
Transparency enabled or the surface is forced to solid. If you ship a custom
palette and care about that case, branch on
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency when
resolving.
See also
Examples/ThemedNook/main.swift- the working palette + lifecycle hooks example this guide mirrors.- Settings chrome - configures the top bar identity that consumes the same theme.
Sources/NookKit/App/NookResolvedTheme.swift- the type’s source of truth including the live resolver.