feat(app): add system appearance following — auto dark/light theme switching - #61
Merged
Conversation
griffinwork40
force-pushed
the
afk/auto-theme
branch
from
August 25, 2026 20:10
498cdab to
3bf26da
Compare
…itching
When a user sets 'preset': 'auto' in their theme config, Umber now watches
NSApp.effectiveAppearance via KVO and switches between the named dark and
light palettes whenever macOS toggles Light/Dark mode. The sidebar, titlebar,
and all AppKit chrome switch with the theme — the window appearance is derived
from the new theme's background luminance exactly as it was before.
Config shape:
{ "theme": { "preset": "auto", "dark": "classic-repaired", "light": "afk-light" } }
Defaults when omitted: dark = classic-repaired, light = afk-light.
All existing pinned-preset behavior is unchanged.
Implementation:
- AppearanceObserver.swift (new): KVO observer that fans theme switches out to
every open Space. Idle when no Space uses auto mode. Held by AppDelegate for
the app's lifetime; observation invalidated on deinit.
- AppConfig.autoTheme: new (dark, light) tuple field, nil for pinned presets
- AppConfig.resolveAutoTheme: fail-soft resolver matching resolveTheme's contract
- AppConfig.load(): picks initial palette from current appearance at load time;
marked @mainactor because it now reads NSApp.effectiveAppearance for auto mode
- Config.swift ThemeSpec: adds dark/light Decodable sub-fields
- StarterConfig.swift: documents auto mode with example and defaults
Constraints met:
- No file over 350 LOC (Config.swift at 344, all others well clear)
- Pinned-preset path: zero code changes in SpaceWindowController or appearance pin
- @mainactor throughout; no try!, no non-compile-time force-unwraps
- Fail-soft: bad dark/light name warns and falls back to defaults
griffinwork40
force-pushed
the
afk/auto-theme
branch
from
August 25, 2026 20:14
3bf26da to
d63be0f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Auto dark/light theme switching
Adds system appearance following so Umber automatically switches between a dark and light palette when macOS toggles Light/Dark mode. The feature is opt-in via
"preset": "auto"in the theme config — all existing pinned-preset behaviour is completely unchanged.Config shape
{ "theme": { "preset": "auto", "dark": "classic-repaired", "light": "afk-light" } }Both
darkandlightcan be any shipped preset name. Omitting either falls back toclassic-repaired(dark) orafk-light(light). Existing config like"preset": "umber"continues to work identically — no observer fires, the appearance pin inSpaceWindowControlleris unchanged.What switches
When the system appearance changes and auto mode is active:
AppConfig.appearance, so the sidebar, titlebar, scroller knob, and selection pill all follow automaticallyImplementation
New file:
AppearanceObserver.swift(124 LOC)KVO on
NSApp.effectiveAppearance. The observer is a@MainActor final classheld byAppDelegatefor the app's lifetime. When the appearance changes,applyThemeSwitch()iteratesSpaceWindowController.open, checks each Space'sconfig.autoTheme, and callsspace.apply(config:)+ window chrome updates — the same fan-outreloadConfighas always done.Uses
bestMatch(from:)to handle all four dark-family appearance variants (darkAqua,vibrantDark,accessibilityHighContrastDarkAqua,accessibilityHighContrastVibrantDark) rather than a string comparison.Config.swiftThemeSpecgainsdark: String?andlight: String?Decodable sub-fieldsAppConfiggainsautoTheme: (dark: Theme, light: Theme)?— nil for all pinned presetsAppConfig.load()branches onpreset == "auto", callsresolveAutoTheme, picks initial theme fromAppearanceObserver.currentIsDarkload()is now@MainActorbecause it readsNSApp.effectiveAppearancein auto mode; all callers (AppDelegate) were already on the main actorConfig+Theme.swiftresolveAutoTheme(dark:light:warnings:)— same fail-soft contract asresolveTheme: a bad preset name appends a warning and falls back to the default rather than returning a partial result or throwing.AppDelegate.swiftlet appearanceObserver = AppearanceObserver()— one line, held for app lifetime.StarterConfig.swiftDocuments the auto mode with an inline example and the default values for omitted sub-fields.
Constraints
Config.swiftat 344, all others clearSpaceWindowControllernot touched@MainActorthroughoutAppearanceObserver,currentIsDark,load()try!, no runtime force-unwrapsdark/lightname warns + falls backswift buildcleancheck-file-size.shpassesWhat is NOT done
background,foreground,cursor,ansi) — these are silently ignored whenpresetis"auto". Could be added per-sub-field later if wanted; for now the StarterConfig doc notes they are not supported.AppConfig, so a Space opened before a⌘Rreload with auto mode, and another opened with a pinned preset, coexist correctly. The observer only acts on Spaces with non-nilautoTheme.reloadConfigworks.