Skip to content

feat(themes): follow macOS Appearance and auto-switch dark/light#32

Open
panosAthDBX wants to merge 2 commits intoAtaraxy-Labs:mainfrom
panosAthDBX:feat/auto-theme-follows-system
Open

feat(themes): follow macOS Appearance and auto-switch dark/light#32
panosAthDBX wants to merge 2 commits intoAtaraxy-Labs:mainfrom
panosAthDBX:feat/auto-theme-follows-system

Conversation

@panosAthDBX
Copy link
Copy Markdown

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

  • New module packages/runtime/src/system-theme.ts exposes two helpers:
    • readMacSystemAppearance() — reads defaults read -g AppleInterfaceStyle via Bun.spawn, maps to "dark" or "light", short-circuits on non-darwin.
    • themeForSystemMode(mode, dark, light) — pure mapping.
  • startServer() spawns a 3s setInterval that calls both helpers; when the desired theme differs from currentTheme, it takes the same path as the manual set-theme command: update local state → saveConfigbroadcastState. That means every connected sidebar picks up the switch through the existing WebSocket broadcast — no new wire format.
  • Timer is cleared in 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 defaults subprocess.

Changes

  • New: packages/runtime/src/system-theme.ts (42 lines) — pure helpers.
  • New: 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 on OpensessionsConfig.
  • packages/runtime/src/server/index.ts — import, timer declaration, poller in startServer(), 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 pass
  • bun test packages/runtime/test/config.test.ts — 19/19 pass (2 new + 17 existing)
  • bun test packages/runtime/test — 374/374 pass, no regressions
  • Running locally on darwin 25.4 for a few hours — toggling Appearance in System Settings flips every open sidebar within 3s as expected; disabling the config puts the behavior back to manual

Happy to iterate on the 3s cadence, the default theme pair, or the config key names.

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.
Copy link
Copy Markdown

@inspect-review inspect-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inspect review

Triage: 8 entities analyzed | 0 critical, 0 high, 2 medium, 6 low
Verdict: standard_review

Findings (1)

  1. [low] Config test writes config.json into a directory that is never created, so Bun.write() will fail with ENOENT before loadConfig() is exercised. Evidence: const configDir = join(tmpDir, ".config", "opensessions"); await Bun.write(join(configDir, "config.json"), ...) with no mkdir/ensureDir for configDir.

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
@panosAthDBX
Copy link
Copy Markdown
Author

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 set-theme override was silently overwritten on the next 3s poll. The handler wrote to config.theme but the poll loop only consults darkTheme / lightTheme.

Changes:

  • set-theme now writes to darkTheme or lightTheme based on the current observed appearance
  • The poll loop re-reads darkTheme / lightTheme from disk each cycle so a manual override is picked up immediately
  • Removed the saveConfig({ theme: desired }) write inside the poll loop (was clobbering the user's static-mode theme field)

All 374 tests still pass.

Copy link
Copy Markdown

@inspect-review inspect-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inspect review

Triage: 11 entities analyzed | 0 critical, 0 high, 3 medium, 8 low
Verdict: standard_review

Findings (2)

  1. [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 no mkdir for configDir.
  2. [low] Test bug: system-theme.test attempts to redefine process.platform via Object.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

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