feat(themes): follow macOS Appearance and auto-switch dark/light#32
feat(themes): follow macOS Appearance and auto-switch dark/light#32panosAthDBX wants to merge 2 commits intoAtaraxy-Labs:mainfrom
Conversation
Adds an opt-in background poller that reads the macOS Appearance setting (`defaults read -g AppleInterfaceStyle`) every few seconds and flips the active theme between a configured dark and light pair when the user toggles System Settings → Appearance. Config: autoThemeFollowsSystem: true darkTheme: "catppuccin-mocha" (default) lightTheme: "catppuccin-latte" (default) macOS-gated — no-op on Linux/Windows. Uses the same broadcast path as the manual theme picker so every connected sidebar re-renders in sync. Poller is a 3s interval cleaned up on shutdown. Extracted pure mapping and side-effectful read into `system-theme.ts` for testability. Covered by 5 new tests.
There was a problem hiding this comment.
inspect review
Triage: 8 entities analyzed | 0 critical, 0 high, 2 medium, 6 low
Verdict: standard_review
Findings (1)
- [low] Config test writes config.json into a directory that is never created, so
Bun.write()will fail with ENOENT beforeloadConfig()is exercised. Evidence:const configDir = join(tmpDir, ".config", "opensessions"); await Bun.write(join(configDir, "config.json"), ...)with nomkdir/ensureDir forconfigDir.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
When `autoThemeFollowsSystem` is on and the user manually picks a theme via
`set-theme`, route the persistence to `darkTheme` or `lightTheme` based on the
current macOS appearance instead of `theme`. Previously the choice was written
to `theme`, which the poll loop ignored, so the manual override was silently
overwritten on the next 3s tick.
Also re-load `darkTheme` / `lightTheme` from disk inside the poll cycle so an
override made via `set-theme` is picked up immediately on the next poll.
Drops the `saveConfig({ theme: desired })` write inside the poll loop — that
was clobbering the user's static-mode `theme` field whenever auto-follow ran.
Co-authored-by: Isaac
|
Pushed a follow-up fix on top of the original commit: fix(themes): persist manual override per system appearance The original implementation had a bug where, with auto-follow on, a manual Changes:
All 374 tests still pass. |
There was a problem hiding this comment.
inspect review
Triage: 11 entities analyzed | 0 critical, 0 high, 3 medium, 8 low
Verdict: standard_review
Findings (2)
- [low] Test bug: config round-trip test writes to a config path whose parent directories are never created, so
Bun.write(join(configDir, "config.json"), ...)can fail before assertions. Evidence:const configDir = join(tmpDir, ".config", "opensessions"); await Bun.write(join(configDir, "config.json"), ...)with nomkdirforconfigDir. - [low] Test bug: system-theme.test attempts to redefine
process.platformviaObject.defineProperty. In many runtimes this property is non-configurable/read-only, so redefining it can throw and fail the test. Evidence:Object.defineProperty(process, "platform", { value: "linux", configurable: true });and later restoring similarly.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
Summary
Adds an opt-in feature that makes the sidebar track the macOS system Appearance setting: when the user toggles System Settings → Appearance between Light and Dark, the active theme flips to their configured dark/light pair within ~3 seconds, and every connected sidebar re-renders in sync.
macOS-gated — no-op on Linux/Windows.
Config (all opt-in)
{ "autoThemeFollowsSystem": true, "darkTheme": "catppuccin-mocha", "lightTheme": "catppuccin-latte" }autoThemeFollowsSystem— enables the poller (default: off).darkTheme/lightTheme— any of the 20 built-in theme names; default to the catppuccin pair that best matches the existing default theme.How it works
packages/runtime/src/system-theme.tsexposes two helpers:readMacSystemAppearance()— readsdefaults read -g AppleInterfaceStyleviaBun.spawn, maps to"dark"or"light", short-circuits on non-darwin.themeForSystemMode(mode, dark, light)— pure mapping.startServer()spawns a 3ssetIntervalthat calls both helpers; when the desired theme differs fromcurrentTheme, it takes the same path as the manualset-themecommand: update local state →saveConfig→broadcastState. That means every connected sidebar picks up the switch through the existing WebSocket broadcast — no new wire format.cleanup()alongside the other interval timers, so it unwinds cleanly on SIGINT/SIGTERM.macOS doesn't expose a CLI-visible change notification for Appearance, so polling is pragmatic; each poll is one cheap
defaultssubprocess.Changes
packages/runtime/src/system-theme.ts(42 lines) — pure helpers.packages/runtime/test/system-theme.test.ts— 5 tests covering the mapping + non-darwin short-circuit.packages/runtime/src/config.ts— three new optional fields onOpensessionsConfig.packages/runtime/src/server/index.ts— import, timer declaration, poller instartServer(), cleanup teardown.packages/runtime/test/config.test.ts— 2 tests for the new fields (round-trip + unset defaults).Test plan
bun test packages/runtime/test/system-theme.test.ts— 5/5 passbun test packages/runtime/test/config.test.ts— 19/19 pass (2 new + 17 existing)bun test packages/runtime/test— 374/374 pass, no regressionsHappy to iterate on the 3s cadence, the default theme pair, or the config key names.