Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@
<!-- Preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<!--
#750: Applies the stored theme class to <html> before the first
paint so there is no flash of the wrong theme on load.
Reads localStorage['theme']; falls back to prefers-color-scheme.
-->
<script>
(function () {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<!--
Expand Down
21 changes: 20 additions & 1 deletion src/components/home/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
import WalletStatusChip from '@/components/common/WalletStatusChip';
import NotificationBell from '@/components/common/NotificationBell';
import { useProfileStore } from '@/hooks/useProfileStore';
import { useTheme } from '@/hooks/useTheme';
import { Link } from 'react-router';

const navLinks = [
Expand All @@ -13,6 +15,7 @@ const navLinks = [
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const profile = useProfileStore(state => state.profile);
const { theme, toggleTheme } = useTheme();

useEffect(() => {
const onScroll = () => {
Expand Down Expand Up @@ -69,9 +72,25 @@ export default function Header() {
)}
</nav>

{/* Right-side actions: notification bell (#720) + wallet status chip (#686).
{/* Right-side actions: dark mode toggle (#750) + notification bell (#720) + wallet status chip (#686).
NotificationBell is only rendered when a user profile is available. */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
className={`rounded-md p-1.5 transition-colors duration-200 ${
scrolled
? 'text-gray-600 hover:bg-black/5 hover:text-gray-900'
: 'text-white/60 hover:text-white/90'
}`}
>
{theme === 'dark' ? (
<Sun className="size-4" aria-hidden="true" />
) : (
<Moon className="size-4" aria-hidden="true" />
)}
</button>
{profile && (
<NotificationBell
userId={profile.id}
Expand Down
163 changes: 163 additions & 0 deletions src/hooks/__tests__/useTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { THEME_STORAGE_KEY, useTheme } from '@/hooks/useTheme';

const localStorageStub = (() => {
let store: Record<string, string> = {};
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v; },
removeItem: (k: string) => { delete store[k]; },
clear: () => { store = {}; },
};
})();

function setOsPreference(prefersDark: boolean) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({
matches: prefersDark,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
}

beforeEach(() => {
localStorageStub.clear();
vi.stubGlobal('localStorage', localStorageStub);
document.documentElement.classList.remove('dark');
setOsPreference(false);
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('useTheme initial value', () => {
it('defaults to light when localStorage is empty and OS prefers light', () => {
setOsPreference(false);
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('light');
});

it('defaults to dark when localStorage is empty and OS prefers dark', () => {
setOsPreference(true);
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dark');
});

it('reads stored light theme from localStorage over OS dark preference', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'light');
setOsPreference(true);
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('light');
});

it('reads stored dark theme from localStorage over OS light preference', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'dark');
setOsPreference(false);
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dark');
});

it('ignores an invalid localStorage value and falls back to OS preference', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'system');
setOsPreference(true);
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dark');
});
});

describe('useTheme html class management', () => {
it('adds the dark class to <html> when theme is dark', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'dark');
renderHook(() => useTheme());
expect(document.documentElement.classList.contains('dark')).toBe(true);
});

it('removes the dark class from <html> when theme is light', () => {
document.documentElement.classList.add('dark');
localStorageStub.setItem(THEME_STORAGE_KEY, 'light');
renderHook(() => useTheme());
expect(document.documentElement.classList.contains('dark')).toBe(false);
});

it('adds the dark class after toggling from light to dark', () => {
const { result } = renderHook(() => useTheme());
expect(document.documentElement.classList.contains('dark')).toBe(false);

act(() => { result.current.toggleTheme(); });

expect(document.documentElement.classList.contains('dark')).toBe(true);
});

it('removes the dark class after toggling from dark to light', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'dark');
const { result } = renderHook(() => useTheme());
expect(document.documentElement.classList.contains('dark')).toBe(true);

act(() => { result.current.toggleTheme(); });

expect(document.documentElement.classList.contains('dark')).toBe(false);
});
});

describe('useTheme localStorage persistence', () => {
it('writes the initial theme to localStorage on mount', () => {
setOsPreference(false);
renderHook(() => useTheme());
expect(localStorageStub.getItem(THEME_STORAGE_KEY)).toBe('light');
});

it('persists dark theme to localStorage after toggling', () => {
const { result } = renderHook(() => useTheme());

act(() => { result.current.toggleTheme(); });

expect(localStorageStub.getItem(THEME_STORAGE_KEY)).toBe('dark');
});

it('persists light theme to localStorage after toggling back', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'dark');
const { result } = renderHook(() => useTheme());

act(() => { result.current.toggleTheme(); });

expect(localStorageStub.getItem(THEME_STORAGE_KEY)).toBe('light');
});
});

describe('useTheme toggleTheme', () => {
it('switches from light to dark', () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('light');

act(() => { result.current.toggleTheme(); });

expect(result.current.theme).toBe('dark');
});

it('switches from dark to light', () => {
localStorageStub.setItem(THEME_STORAGE_KEY, 'dark');
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dark');

act(() => { result.current.toggleTheme(); });

expect(result.current.theme).toBe('light');
});

it('toggles back and forth correctly', () => {
const { result } = renderHook(() => useTheme());

act(() => { result.current.toggleTheme(); });
expect(result.current.theme).toBe('dark');

act(() => { result.current.toggleTheme(); });
expect(result.current.theme).toBe('light');

act(() => { result.current.toggleTheme(); });
expect(result.current.theme).toBe('dark');
});
});
35 changes: 35 additions & 0 deletions src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from 'react';

export type Theme = 'light' | 'dark';

export const THEME_STORAGE_KEY = 'theme';

function resolveInitialTheme(): Theme {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}

export interface UseThemeResult {
theme: Theme;
toggleTheme: () => void;
}

export function useTheme(): UseThemeResult {
const [theme, setTheme] = useState<Theme>(resolveInitialTheme);

useEffect(() => {
applyTheme(theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme]);

const toggleTheme = useCallback(() => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
}, []);

return { theme, toggleTheme };
}
Loading