Skip to content

Light/dark/system theme mode + custom themes from disk - #47

Merged
frenchie4111 merged 3 commits into
ness-dev:mainfrom
pgodschalk:feat/theme-mode-light-dark-system
May 22, 2026
Merged

frenchie4111 merged 3 commits into
ness-dev:mainfrom
pgodschalk:feat/theme-mode-light-dark-system

Conversation

@pgodschalk

Copy link
Copy Markdown
Contributor
Screenshot 2026-05-22 at 15 29 31

Phase 1 — Mode model. Replaces the single theme: string setting with themeMode ('light' | 'dark' | 'system') plus themeLight and themeDark slots. With mode=system, the renderer follows the OS color scheme live via matchMedia('(prefers-color-scheme: dark)'). Migration v6→v7 maps the old theme string into the new slots, preserving the user's explicit mode (not forcing 'system').

Phase 2 — User-authored themes from <userData>/themes/*.json. The loader validates name/mode/optional colors and silently skips malformed files. Custom themes show up in the mode-filtered pickers alongside built-ins; the apply helper sets only the keys the JSON overrides as inline --color-* properties on documentElement, with the matching mode's default theme as the [data-theme] base so missing keys inherit sensibly. Inline keys are tracked per apply so a switch back to a built-in cleanly clears leftovers (inline styles would otherwise outrank attribute selectors).

  • New slice fields + 4 events; one reducer test per event variant
  • IPC: setThemeMode / setThemeLight / setThemeDark + setLastEffectiveAppBg (fire-and-forget) + reloadCustomThemes + openThemesFolder
  • New useSystemColorScheme + useActiveTheme hooks + applyTheme / effectiveAppBg helpers; ResolvedTheme is a tagged union so callers don't branch on origin
  • Settings UI: native-radio Mode segmented control inside fieldset/ legend, filtered Light/Dark pickers (built-ins + customs), Custom themes section with Open / Reload buttons
  • Onboarding: dark-only picker, less judgmental copy, writes themeDark
  • Window first paint uses nativeTheme.shouldUseDarkColors when mode is 'system', falls back through lastEffectiveAppBg then #0a0a0a
  • IPC validation accepts any currently-loaded custom-theme id of the matching mode (not just AVAILABLE_THEMES)

Phase 1 — Mode model. Replaces the single `theme: string` setting with
`themeMode` ('light' | 'dark' | 'system') plus `themeLight` and
`themeDark` slots. With mode=system, the renderer follows the OS color
scheme live via `matchMedia('(prefers-color-scheme: dark)')`. Migration
v6→v7 maps the old `theme` string into the new slots, preserving the
user's explicit mode (not forcing 'system').

Phase 2 — User-authored themes from `<userData>/themes/*.json`. The
loader validates `name`/`mode`/optional `colors` and silently skips
malformed files. Custom themes show up in the mode-filtered pickers
alongside built-ins; the apply helper sets only the keys the JSON
overrides as inline `--color-*` properties on documentElement, with the
matching mode's default theme as the [data-theme] base so missing keys
inherit sensibly. Inline keys are tracked per apply so a switch back to
a built-in cleanly clears leftovers (inline styles would otherwise
outrank attribute selectors).

- New slice fields + 4 events; one reducer test per event variant
- IPC: setThemeMode / setThemeLight / setThemeDark + setLastEffectiveAppBg
  (fire-and-forget) + reloadCustomThemes + openThemesFolder
- New `useSystemColorScheme` + `useActiveTheme` hooks + `applyTheme` /
  `effectiveAppBg` helpers; ResolvedTheme is a tagged union so callers
  don't branch on origin
- Settings UI: native-radio Mode segmented control inside fieldset/
  legend, filtered Light/Dark pickers (built-ins + customs), Custom
  themes section with Open / Reload buttons
- Onboarding: dark-only picker, less judgmental copy, writes themeDark
- Window first paint uses `nativeTheme.shouldUseDarkColors` when mode
  is 'system', falls back through `lastEffectiveAppBg` then `#0a0a0a`
- IPC validation accepts any currently-loaded custom-theme id of the
  matching mode (not just AVAILABLE_THEMES)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new files and the reformatted build-initial-state.ts landed with
tabs + double quotes + semicolons from a Biome-with-defaults editor.
Bring them in line with the rest of the codebase (2-space indent,
single quotes, no trailing semicolons, plain 'fs'/'path' imports
without the node: prefix).

No behavior changes. Drift-prevention follow-up tracked in #48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@frenchie4111

Copy link
Copy Markdown
Collaborator

Comment from Claude on behalf of @frenchie4111.

Pushed a follow-up commit (0bc6b17) reformatting the five files that landed with tabs + double quotes + trailing semicolons to match the rest of the codebase (2-space indent, single quotes, no semis, plain 'fs'/'path' imports without the node: prefix):

  • src/main/build-initial-state.ts
  • src/main/themes-loader.ts
  • src/renderer/hooks/useActiveTheme.ts
  • src/renderer/hooks/useSystemColorScheme.ts
  • src/renderer/theme-apply.ts

No behavior changes — pure style. Verified npm run typecheck, npx electron-vite build, and the theme-adjacent vitest suites (settings, persistence-migrations, store, build-initial-state — 196/196 pass).

To prevent this kind of drift going forward, opened #48 to add a code formatter (Prettier or Biome) with CI enforcement.

The strings 'solarized-light' and 'dark' as the per-mode defaults were
sprinkled across six call sites (build-initial-state, desktop-shell,
index.ts IPC handlers, useActiveTheme local consts, theme-apply
data-theme fallback). Replace with two shared constants in
src/shared/state/settings.ts so main and renderer agree on the
default through the same import.

Drops the now-redundant `DEFAULT_THEME` from persistence.ts (only one
caller; replaced with `DEFAULT_DARK_THEME`).

No behavior changes — same string values, just one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@frenchie4111

frenchie4111 commented May 22, 2026 •

Copy link
Copy Markdown
Collaborator

Comment from Claude on behalf of @frenchie4111.

Also pushed b16ce0e to address review point #2 — extracted the per-mode default theme ids into shared constants.

  • Added DEFAULT_LIGHT_THEME = 'solarized-light' and DEFAULT_DARK_THEME = 'dark' in src/shared/state/settings.ts (next to EMPTY_CUSTOM_THEMES) so main and renderer share the same source of truth.
  • Replaced the literal strings in build-initial-state.ts, desktop-shell.ts:resolveWindowBg, index.ts setThemeLight/setThemeDark handlers, useActiveTheme.ts (dropped the local DEFAULT_DARK_ID / DEFAULT_LIGHT_ID), and the custom-theme base in theme-apply.ts.
  • Removed the now-redundant DEFAULT_THEME from persistence.ts (it had one caller).

No behavior changes — identical string values, just one source. Verified npm run typecheck, npx electron-vite build, and 206/206 theme-adjacent vitest cases pass.

@frenchie4111

Copy link
Copy Markdown
Collaborator

this is awesome thank you @pgodschalk for becoming a contributor!

@frenchie4111
frenchie4111 merged commit 99262b2 into ness-dev:main May 22, 2026
@big-guy big-guy mentioned this pull request May 23, 2026
2 of 5 tasks
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.

2 participants