Skip to content

feat(theme): tri-state mode (system/light/dark) with live OS sync#5412

Merged
MarkusNeusinger merged 1 commit intomainfrom
claude/tri-state-theme-mode-gYOTt
Apr 25, 2026
Merged

feat(theme): tri-state mode (system/light/dark) with live OS sync#5412
MarkusNeusinger merged 1 commit intomainfrom
claude/tri-state-theme-mode-gYOTt

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

Closes #5406.

Summary

  • Rewrites useThemeMode to a tri-state hook returning { mode, effective, isDark, setMode, cycle } with mode: 'system' | 'light' | 'dark'.
  • system is the new default and writes nothing to localStorage, so a fresh visit follows prefers-color-scheme and live-flips when the OS theme changes (e.g. iOS sunset auto-dark) — fixing the dead matchMedia listener.
  • Explicit light / dark persists in localStorage; cycling back to system clears the key so the next visit again defaults to OS-following.
  • ThemeToggle becomes a tri-state cycle: ◑ system → ☀ light → ☾ dark → system.
  • Inline pre-paint script in index.html resolves the theme before React hydrates to avoid a flash-of-wrong-theme.

Analytics

  • theme_toggle.to now reports the new mode (system / light / dark) so we can see how often users opt back into system tracking.
  • The ambient theme pageview prop still reports the effective theme (dark / light), so existing Plausible breakdowns keep working.
  • docs/reference/plausible.md updated.

Tests

useThemeMode.test.ts rewritten to cover:

  • First-ever visit defaults to system and persists nothing
  • system → light → dark → system transitions (storage written / cleared correctly)
  • Live OS change while in system flips effective theme
  • OS change while in explicit light / dark is ignored
  • setMode direct path
  • Restore explicit dark from localStorage on remount

Acceptance criteria checklist

  • First-ever visit follows OS preference and persists nothing
  • User toggle persists light / dark in localStorage
  • User can return to system mode and localStorage is cleared
  • When mode is system, OS-level theme changes during the session flip the page live
  • When mode is light / dark, OS changes are ignored
  • theme ambient analytics prop reflects the effective theme
  • theme_toggle event captures the new mode
  • Hook return shape stays ergonomic ({ mode, effective, setMode, cycle })
  • No flash-of-wrong-theme — inline pre-paint script in index.html
  • Tests cover all four transitions

Test plan

  • yarn type-check clean
  • yarn test — 374/374 pass
  • yarn build succeeds
  • Manual: load with localStorage.theme cleared and verify OS preference is honored, that toggling iOS/macOS dark/light mode flips the tab live, and that the cycle button takes you back to system (clearing storage)
  • Manual: load with localStorage.theme = 'dark' set and verify OS changes are ignored
  • Manual: hard-refresh on a slow connection and confirm there is no FOWT

https://claude.ai/code/session_01W3JkYodBsG9p5vB6y7LDPG


Generated by Claude Code

- Rewrites useThemeMode to expose `{ mode, effective, isDark, setMode, cycle }`
- `system` is the new default and writes nothing to localStorage, so a fresh
  visit follows `prefers-color-scheme` and live-flips when the OS theme changes
- Explicit `light`/`dark` persists; cycling back to `system` clears storage
- ThemeToggle becomes a tri-state cycle (◑ system → ☀ light → ☾ dark → …)
- `theme_toggle` analytics event now reports the next mode (`system` |
  `light` | `dark`); ambient `theme` prop still tracks the *effective* theme
  so existing Plausible breakdowns keep working
- Inline pre-paint script in index.html sets `data-theme` before React hydrates
  to avoid a flash-of-wrong-theme
- Tests cover all four transitions, live OS change, and OS-ignore-while-explicit

Closes #5406
Copilot AI review requested due to automatic review settings April 25, 2026 21:51
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 25, 2026

Codecov Report

❌ Patch coverage is 95.12195% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
app/src/hooks/useLayoutContext.ts 33.33% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@MarkusNeusinger MarkusNeusinger merged commit 76c2880 into main Apr 25, 2026
11 checks passed
@MarkusNeusinger MarkusNeusinger deleted the claude/tri-state-theme-mode-gYOTt branch April 25, 2026 21:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a tri-state theme mode (system / light / dark) across the frontend, enabling live OS theme syncing when in system mode, while preserving explicit user choices via localStorage and avoiding a flash-of-wrong-theme before React hydration.

Changes:

  • Rewrites useThemeMode into a tri-state hook exposing { mode, effective, isDark, setMode, cycle } and re-enables live prefers-color-scheme updates.
  • Updates the UI + analytics to reflect tri-state cycling (theme_toggle.to = system|light|dark) while keeping the ambient theme prop as the effective theme.
  • Adds a pre-paint theme resolver script in index.html and updates tests/docs accordingly.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
docs/reference/plausible.md Documents tri-state theme_toggle.to and clarifies ambient theme as effective theme
app/src/hooks/useThemeMode.ts Implements tri-state theme state, OS sync listener, persistence rules
app/src/hooks/useThemeMode.test.ts Rewrites tests to cover system default, cycling, OS-sync behavior, persistence/clearing
app/src/hooks/useLayoutContext.ts Expands ThemeContextValue to include mode, effective, setMode, cycle
app/src/components/ThemeToggle.tsx Updates toggle UI to tri-state and adjusts aria-labeling
app/src/components/RootLayout.tsx Sets analytics ambient theme prop from effective theme
app/src/components/RootLayout.test.tsx Updates useTheme mock to new shape and verifies ambient prop behavior
app/src/components/MastheadRule.tsx Emits theme_toggle with the next mode and cycles theme
app/src/components/MastheadRule.test.tsx Updates assertions for tri-state label + event payload
app/index.html Adds inline pre-paint script to set data-theme before hydration
app/eslint.config.js Adds MediaQueryListEvent to eslint globals for TS source linting

Comment on lines +10 to +25
function readStoredMode(): ThemeMode {
if (typeof window === 'undefined') return 'system';
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'dark' || stored === 'light' ? stored : 'system';
}

function systemPrefersDark(): boolean {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
}

function persistMode(mode: ThemeMode): void {
if (mode === 'system') localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, mode);
}
Comment on lines +42 to +44
export type ThemeMode = 'system' | 'light' | 'dark';
export type EffectiveTheme = 'light' | 'dark';

const next = NEXT_MODE[mode];
return (
<Box
component="button"
Comment on lines +112 to +115
const NEXT_MODE = { system: 'light', light: 'dark', dark: 'system' } as const;
const handleThemeToggle = () => {
trackEvent('theme_toggle', { to: isDark ? 'light' : 'dark' });
toggle();
trackEvent('theme_toggle', { to: NEXT_MODE[mode] });
cycle();
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.

Tri-state theme mode (system/light/dark) with live OS sync

3 participants