Skip to content

Commit 76c2880

Browse files
feat(theme): tri-state mode (system/light/dark) with live OS sync (#5412)
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 - [x] First-ever visit follows OS preference and persists nothing - [x] User toggle persists `light` / `dark` in `localStorage` - [x] User can return to `system` mode and `localStorage` is cleared - [x] When mode is `system`, OS-level theme changes during the session flip the page live - [x] When mode is `light` / `dark`, OS changes are ignored - [x] `theme` ambient analytics prop reflects the **effective** theme - [x] `theme_toggle` event captures the new **mode** - [x] Hook return shape stays ergonomic (`{ mode, effective, setMode, cycle }`) - [x] No flash-of-wrong-theme — inline pre-paint script in `index.html` - [x] Tests cover all four transitions ## Test plan - [x] `yarn type-check` clean - [x] `yarn test` — 374/374 pass - [x] `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](https://claude.ai/code/session_01W3JkYodBsG9p5vB6y7LDPG)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0419497 commit 76c2880

11 files changed

Lines changed: 257 additions & 70 deletions

app/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export default [
5757
ClipboardEvent: 'readonly',
5858
Event: 'readonly',
5959
MessageEvent: 'readonly',
60+
MediaQueryListEvent: 'readonly',
6061
// APIs
6162
AbortController: 'readonly',
6263
AbortSignal: 'readonly',

app/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
<meta name="theme-color" content="#0E0E10" media="(prefers-color-scheme: dark)" />
1111
<title>any.plot() — any library.</title>
1212

13+
<!-- Resolve theme before paint to avoid a flash-of-wrong-theme. Mirrors
14+
useThemeMode: stored 'light'/'dark' wins, otherwise follow the OS. -->
15+
<script>
16+
(function () {
17+
try {
18+
var stored = localStorage.getItem('theme');
19+
var dark = stored === 'dark'
20+
|| (stored !== 'light' && window.matchMedia
21+
&& window.matchMedia('(prefers-color-scheme: dark)').matches);
22+
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
23+
} catch (e) { /* private mode etc. — fall through to React-driven default */ }
24+
})();
25+
</script>
26+
1327
<!-- Open Graph -->
1428
<meta property="og:type" content="website" />
1529
<meta property="og:url" content="https://anyplot.ai/" />

app/src/components/MastheadRule.test.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { render, screen, userEvent } from '../test-utils';
33

44
const trackEvent = vi.fn();
5-
const toggle = vi.fn();
5+
const cycle = vi.fn();
6+
const setMode = vi.fn();
67

78
vi.mock('../hooks', async () => {
89
const actual = await vi.importActual<typeof import('../hooks')>('../hooks');
910
return {
1011
...actual,
1112
useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }),
12-
useTheme: () => ({ isDark: false, toggle }),
13+
useTheme: () => ({ mode: 'system', effective: 'light', isDark: false, setMode, cycle }),
1314
useLatestRelease: () => 'v1.2.3',
1415
};
1516
});
@@ -19,16 +20,18 @@ import { MastheadRule } from './MastheadRule';
1920
describe('MastheadRule', () => {
2021
beforeEach(() => {
2122
trackEvent.mockClear();
22-
toggle.mockClear();
23+
cycle.mockClear();
24+
setMode.mockClear();
2325
});
2426

25-
it('fires theme_toggle event before invoking the underlying toggle', async () => {
27+
it('fires theme_toggle event with the next mode and cycles', async () => {
2628
const user = userEvent.setup();
2729
render(<MastheadRule />);
2830

29-
await user.click(screen.getByLabelText('Switch to dark theme'));
30-
expect(trackEvent).toHaveBeenCalledWith('theme_toggle', { to: 'dark' });
31-
expect(toggle).toHaveBeenCalled();
31+
// mode='system' → next is 'light'
32+
await user.click(screen.getByLabelText('Switch to light theme'));
33+
expect(trackEvent).toHaveBeenCalledWith('theme_toggle', { to: 'light' });
34+
expect(cycle).toHaveBeenCalled();
3235
});
3336

3437
it('tracks nav_click on the masthead logo, branch, and release links', async () => {

app/src/components/MastheadRule.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,18 @@ function pathSegments(pathname: string): Segment[] {
101101
* and the catchphrase is suppressed so the line stays uncluttered.
102102
*/
103103
export function MastheadRule() {
104-
const { isDark, toggle } = useTheme();
104+
const { mode, cycle } = useTheme();
105105
const { trackEvent } = useAnalytics();
106106
const releaseTag = useLatestRelease();
107107
const location = useLocation();
108108
const segments = pathSegments(location.pathname);
109109
const isLanding = segments.length === 0;
110110
const version = releaseTag ?? 'v1.0';
111111

112+
const NEXT_MODE = { system: 'light', light: 'dark', dark: 'system' } as const;
112113
const handleThemeToggle = () => {
113-
trackEvent('theme_toggle', { to: isDark ? 'light' : 'dark' });
114-
toggle();
114+
trackEvent('theme_toggle', { to: NEXT_MODE[mode] });
115+
cycle();
115116
};
116117

117118
// Pick one random comment style per browser session (stable across client-side nav).
@@ -234,7 +235,7 @@ export function MastheadRule() {
234235
display: 'flex',
235236
justifyContent: 'flex-end',
236237
}}>
237-
<ThemeToggle isDark={isDark} onToggle={handleThemeToggle} />
238+
<ThemeToggle mode={mode} onCycle={handleThemeToggle} />
238239
</Box>
239240
</Box>
240241
);

app/src/components/RootLayout.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,16 @@ vi.mock('../hooks', async () => {
2222

2323
vi.mock('../hooks/useLayoutContext', async () => {
2424
const actual = await vi.importActual<typeof import('../hooks/useLayoutContext')>('../hooks/useLayoutContext');
25-
return { ...actual, useTheme: () => ({ isDark: true, toggle: vi.fn() }) };
25+
return {
26+
...actual,
27+
useTheme: () => ({
28+
mode: 'dark' as const,
29+
effective: 'dark' as const,
30+
isDark: true,
31+
setMode: vi.fn(),
32+
cycle: vi.fn(),
33+
}),
34+
};
2635
});
2736

2837
vi.mock('./MastheadRule', () => ({ MastheadRule: () => <div data-testid="masthead" /> }));

app/src/components/RootLayout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@ export function RootLayout() {
2727
const { trackEvent } = useAnalytics();
2828
const { pathname, hash } = useLocation();
2929
const navigationType = useNavigationType();
30-
const { isDark } = useTheme();
30+
const { effective } = useTheme();
3131
const mastheadSticks = pathname !== '/plots';
3232

3333
// Set synchronously during render so the first pageview from a child page's
3434
// useEffect (which runs before the parent's useEffect) carries the theme prop.
3535
// setAnalyticsAmbientProps merges into module state, so re-renders are safe.
36-
setAnalyticsAmbientProps({ theme: isDark ? 'dark' : 'light' });
36+
// The ambient prop tracks the *effective* theme so existing Plausible
37+
// dark/light breakdowns keep working regardless of mode (system/light/dark).
38+
setAnalyticsAmbientProps({ theme: effective });
3739

3840
// Reset scroll on forward navigation (PUSH/REPLACE). In SPA route changes,
3941
// the next page can otherwise keep the previous page's scroll position,

app/src/components/ThemeToggle.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
import Box from '@mui/material/Box';
22
import { colors, typography } from '../theme';
3+
import type { ThemeMode } from '../hooks/useLayoutContext';
34

45
interface ThemeToggleProps {
5-
isDark: boolean;
6-
onToggle: () => void;
6+
mode: ThemeMode;
7+
onCycle: () => void;
78
}
89

9-
export function ThemeToggle({ isDark, onToggle }: ThemeToggleProps) {
10-
const icon = isDark ? '☀' : '◐';
11-
const label = isDark ? 'light' : 'dark';
10+
const NEXT_MODE: Record<ThemeMode, ThemeMode> = {
11+
system: 'light',
12+
light: 'dark',
13+
dark: 'system',
14+
};
15+
16+
const ICON: Record<ThemeMode, string> = {
17+
system: '◑',
18+
light: '☀',
19+
dark: '☾',
20+
};
21+
22+
export function ThemeToggle({ mode, onCycle }: ThemeToggleProps) {
23+
const next = NEXT_MODE[mode];
1224
return (
1325
<Box
1426
component="button"
15-
onClick={onToggle}
16-
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
27+
onClick={onCycle}
28+
aria-label={`Switch to ${next} theme`}
29+
title={`theme: ${mode}`}
1730
sx={{
1831
background: 'none',
1932
border: '1px solid var(--rule)',
@@ -32,9 +45,9 @@ export function ThemeToggle({ isDark, onToggle }: ThemeToggleProps) {
3245
},
3346
}}
3447
>
35-
{icon}
48+
{ICON[mode]}
3649
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, ml: 0.5 }}>
37-
{label}
50+
{mode}
3851
</Box>
3952
</Box>
4053
);

app/src/hooks/useLayoutContext.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,26 @@ export const initialHomeState: HomeState = {
3939
initialized: false,
4040
};
4141

42+
export type ThemeMode = 'system' | 'light' | 'dark';
43+
export type EffectiveTheme = 'light' | 'dark';
44+
4245
export interface ThemeContextValue {
46+
mode: ThemeMode;
47+
effective: EffectiveTheme;
4348
isDark: boolean;
44-
toggle: () => void;
49+
setMode: (mode: ThemeMode) => void;
50+
cycle: () => void;
4551
}
4652

4753
export const AppDataContext = createContext<AppData | null>(null);
4854
export const HomeStateContext = createContext<HomeStateContextValue | null>(null);
49-
export const ThemeContext = createContext<ThemeContextValue>({ isDark: false, toggle: () => {} });
55+
export const ThemeContext = createContext<ThemeContextValue>({
56+
mode: 'system',
57+
effective: 'light',
58+
isDark: false,
59+
setMode: () => {},
60+
cycle: () => {},
61+
});
5062

5163
export function useAppData() {
5264
const context = useContext(AppDataContext);

app/src/hooks/useThemeMode.test.ts

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,142 @@
1-
import { describe, it, expect, beforeEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { renderHook, act } from '@testing-library/react';
33
import { useThemeMode } from './useThemeMode';
44

5+
type MQListener = (e: MediaQueryListEvent) => void;
6+
7+
interface MockMediaQuery {
8+
matches: boolean;
9+
media: string;
10+
addEventListener: (type: 'change', listener: MQListener) => void;
11+
removeEventListener: (type: 'change', listener: MQListener) => void;
12+
// Test-only helpers
13+
_emit: (matches: boolean) => void;
14+
}
15+
16+
function installMatchMedia(initialDark: boolean) {
17+
let matches = initialDark;
18+
const listeners = new Set<MQListener>();
19+
const mq: MockMediaQuery = {
20+
get matches() { return matches; },
21+
set matches(v: boolean) { matches = v; },
22+
media: '(prefers-color-scheme: dark)',
23+
addEventListener: (_type, listener) => { listeners.add(listener); },
24+
removeEventListener: (_type, listener) => { listeners.delete(listener); },
25+
_emit(next: boolean) {
26+
matches = next;
27+
const event = { matches: next, media: mq.media } as MediaQueryListEvent;
28+
listeners.forEach(l => l(event));
29+
},
30+
};
31+
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mq));
32+
return mq;
33+
}
34+
535
describe('useThemeMode', () => {
36+
let mq: MockMediaQuery;
37+
638
beforeEach(() => {
739
localStorage.clear();
840
document.documentElement.removeAttribute('data-theme');
41+
mq = installMatchMedia(false);
42+
});
43+
44+
afterEach(() => {
45+
vi.unstubAllGlobals();
946
});
1047

11-
it('defaults to light mode when no stored preference', () => {
48+
it('first-ever visit defaults to system mode and persists nothing', () => {
1249
const { result } = renderHook(() => useThemeMode());
50+
expect(result.current.mode).toBe('system');
51+
expect(result.current.effective).toBe('light');
1352
expect(result.current.isDark).toBe(false);
53+
expect(localStorage.getItem('theme')).toBeNull();
54+
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
55+
});
56+
57+
it('system mode follows the OS preference at mount', () => {
58+
mq._emit(true); // OS prefers dark before mount
59+
const { result } = renderHook(() => useThemeMode());
60+
expect(result.current.mode).toBe('system');
61+
expect(result.current.effective).toBe('dark');
62+
expect(localStorage.getItem('theme')).toBeNull();
1463
});
1564

16-
it('sets data-theme attribute on html element', () => {
17-
renderHook(() => useThemeMode());
65+
it('system → light transition persists the choice', () => {
66+
const { result } = renderHook(() => useThemeMode());
67+
68+
act(() => { result.current.cycle(); });
69+
70+
expect(result.current.mode).toBe('light');
71+
expect(result.current.effective).toBe('light');
72+
expect(localStorage.getItem('theme')).toBe('light');
1873
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
1974
});
2075

21-
it('toggles to dark mode', () => {
76+
it('light → dark transition persists the new choice', () => {
77+
localStorage.setItem('theme', 'light');
2278
const { result } = renderHook(() => useThemeMode());
2379

24-
act(() => {
25-
result.current.toggle();
26-
});
80+
act(() => { result.current.cycle(); });
2781

28-
expect(result.current.isDark).toBe(true);
29-
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
82+
expect(result.current.mode).toBe('dark');
83+
expect(result.current.effective).toBe('dark');
3084
expect(localStorage.getItem('theme')).toBe('dark');
85+
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
3186
});
3287

33-
it('restores dark mode from localStorage', () => {
88+
it('dark → system transition clears localStorage and re-follows the OS', () => {
3489
localStorage.setItem('theme', 'dark');
90+
mq._emit(false); // OS prefers light
3591
const { result } = renderHook(() => useThemeMode());
36-
expect(result.current.isDark).toBe(true);
92+
expect(result.current.mode).toBe('dark');
93+
94+
act(() => { result.current.cycle(); });
95+
96+
expect(result.current.mode).toBe('system');
97+
expect(result.current.effective).toBe('light');
98+
expect(localStorage.getItem('theme')).toBeNull();
99+
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
37100
});
38101

39-
it('toggles back to light mode', () => {
40-
localStorage.setItem('theme', 'dark');
102+
it('live OS change flips the theme while in system mode', () => {
41103
const { result } = renderHook(() => useThemeMode());
104+
expect(result.current.effective).toBe('light');
42105

43-
act(() => {
44-
result.current.toggle();
45-
});
106+
act(() => { mq._emit(true); });
46107

47-
expect(result.current.isDark).toBe(false);
48-
expect(localStorage.getItem('theme')).toBe('light');
108+
expect(result.current.effective).toBe('dark');
109+
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
110+
expect(localStorage.getItem('theme')).toBeNull();
111+
});
112+
113+
it('explicit light/dark mode ignores OS-level changes', () => {
114+
localStorage.setItem('theme', 'light');
115+
const { result } = renderHook(() => useThemeMode());
116+
117+
act(() => { mq._emit(true); }); // OS flips to dark — should be ignored
118+
119+
expect(result.current.mode).toBe('light');
120+
expect(result.current.effective).toBe('light');
121+
});
122+
123+
it('setMode jumps directly to a target mode', () => {
124+
const { result } = renderHook(() => useThemeMode());
125+
126+
act(() => { result.current.setMode('dark'); });
127+
expect(result.current.mode).toBe('dark');
128+
expect(localStorage.getItem('theme')).toBe('dark');
129+
130+
act(() => { result.current.setMode('system'); });
131+
expect(result.current.mode).toBe('system');
132+
expect(localStorage.getItem('theme')).toBeNull();
133+
});
134+
135+
it('restores explicit dark mode from localStorage on remount', () => {
136+
localStorage.setItem('theme', 'dark');
137+
const { result } = renderHook(() => useThemeMode());
138+
expect(result.current.mode).toBe('dark');
139+
expect(result.current.effective).toBe('dark');
140+
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
49141
});
50142
});

0 commit comments

Comments
 (0)