Skip to content

feat(app): add system appearance following — auto dark/light theme switching - #61

Merged
griffinwork40 merged 1 commit into
mainfrom
afk/auto-theme
Aug 25, 2026
Merged

feat(app): add system appearance following — auto dark/light theme switching#61
griffinwork40 merged 1 commit into
mainfrom
afk/auto-theme

Conversation

@griffinwork40

Copy link
Copy Markdown
Owner

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 dark and light can be any shipped preset name. Omitting either falls back to classic-repaired (dark) or afk-light (light). Existing config like "preset": "umber" continues to work identically — no observer fires, the appearance pin in SpaceWindowController is unchanged.

What switches

When the system appearance changes and auto mode is active:

  • Terminal theme — the full palette (background, foreground, ANSI 16 colours, cursor, selection)
  • Window appearance — derived from the new theme's background luminance via AppConfig.appearance, so the sidebar, titlebar, scroller knob, and selection pill all follow automatically
  • Tab strip chrome — background and foreground recalculated from the new theme

Implementation

New file: AppearanceObserver.swift (124 LOC)

KVO on NSApp.effectiveAppearance. The observer is a @MainActor final class held by AppDelegate for the app's lifetime. When the appearance changes, applyThemeSwitch() iterates SpaceWindowController.open, checks each Space's config.autoTheme, and calls space.apply(config:) + window chrome updates — the same fan-out reloadConfig has always done.

Uses bestMatch(from:) to handle all four dark-family appearance variants (darkAqua, vibrantDark, accessibilityHighContrastDarkAqua, accessibilityHighContrastVibrantDark) rather than a string comparison.

Config.swift

  • ThemeSpec gains dark: String? and light: String? Decodable sub-fields
  • AppConfig gains autoTheme: (dark: Theme, light: Theme)? — nil for all pinned presets
  • AppConfig.load() branches on preset == "auto", calls resolveAutoTheme, picks initial theme from AppearanceObserver.currentIsDark
  • load() is now @MainActor because it reads NSApp.effectiveAppearance in auto mode; all callers (AppDelegate) were already on the main actor

Config+Theme.swift

resolveAutoTheme(dark:light:warnings:) — same fail-soft contract as resolveTheme: a bad preset name appends a warning and falls back to the default rather than returning a partial result or throwing.

AppDelegate.swift

let appearanceObserver = AppearanceObserver() — one line, held for app lifetime.

StarterConfig.swift

Documents the auto mode with an inline example and the default values for omitted sub-fields.

Constraints

Constraint Status
No file over 350 LOC Config.swift at 344, all others clear
Pinned-preset path unchanged SpaceWindowController not touched
@MainActor throughout AppearanceObserver, currentIsDark, load()
No try!, no runtime force-unwraps
Config parsing fails soft ✅ bad dark/light name warns + falls back
swift build clean ✅ 0 errors, 0 new warnings
check-file-size.sh passes

What is NOT done

  • Color overrides in auto mode (background, foreground, cursor, ansi) — these are silently ignored when preset is "auto". Could be added per-sub-field later if wanted; for now the StarterConfig doc notes they are not supported.
  • Per-Space auto vs. pinned mix — each Space carries its own resolved AppConfig, so a Space opened before a ⌘R reload with auto mode, and another opened with a pinned preset, coexist correctly. The observer only acts on Spaces with non-nil autoTheme.
  • Smooth cross-fade — the switch is instant (same frame as the system switch), which is consistent with how reloadConfig works.

…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
griffinwork40 merged commit dbf372d into main Aug 25, 2026
1 check failed
@griffinwork40
griffinwork40 deleted the afk/auto-theme branch August 25, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant