diff --git a/app/layout.tsx b/app/layout.tsx
index e51d28d..3174a53 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -8,6 +8,7 @@ import TopLoader from '@/components/ui/TopLoader';
import CommandPalette from '@/components/ui/CommandPalette';
import ToastProvider from '@/components/providers/ToastProvider';
import ModalProvider from '@/components/providers/ModalProvider';
+import { themeService } from '@/services/themeService';
export const metadata = {
title: 'SwiftChain',
@@ -21,6 +22,11 @@ export default function RootLayout({
}) {
return (
+
+
+
diff --git a/hooks/__tests__/useTheme.test.ts b/hooks/__tests__/useTheme.test.ts
index d2dc10e..b6feb98 100644
--- a/hooks/__tests__/useTheme.test.ts
+++ b/hooks/__tests__/useTheme.test.ts
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { useTheme } from '@/hooks/useTheme';
import { themeService } from '@/services/themeService';
@@ -11,6 +11,11 @@ jest.mock('next-themes', () => ({
jest.mock('@/services/themeService', () => ({
themeService: {
+ getStoredThemePreference: jest.fn(() => null),
+ setStoredThemePreference: jest.fn(),
+ getInitialTheme: jest.fn(() => 'light'),
+ getThemeScript: jest.fn(() => ''),
+ applyTheme: jest.fn(),
getThemePreference: jest.fn(),
saveThemePreference: jest.fn(),
},
@@ -62,6 +67,28 @@ describe('useTheme', () => {
expect(themeService.saveThemePreference).toHaveBeenCalledWith('light');
});
+ it('should prefer the stored local override over the backend/system theme on load', async () => {
+ window.localStorage.setItem('theme', 'light');
+ (themeService.getStoredThemePreference as jest.Mock).mockReturnValue('light');
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: query === '(prefers-color-scheme: dark)',
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })) as typeof window.matchMedia;
+
+ const { result } = renderHook(() => useTheme());
+
+ await waitFor(() => {
+ expect(mockSetTheme).toHaveBeenCalledWith('light');
+ });
+
+ expect(result.current.resolvedTheme).toBe('light');
+ });
+
it('should not throw when persistence fails', async () => {
(themeService.saveThemePreference as jest.Mock).mockRejectedValueOnce(
new Error('Persistence failed')
diff --git a/hooks/useTheme.ts b/hooks/useTheme.ts
index 2d80825..fbff167 100644
--- a/hooks/useTheme.ts
+++ b/hooks/useTheme.ts
@@ -9,14 +9,32 @@ export function useTheme() {
let isMounted = true;
const loadThemePreference = async () => {
+ const storedTheme = themeService.getStoredThemePreference();
+
+ if (storedTheme) {
+ themeService.applyTheme(storedTheme);
+ setTheme(storedTheme);
+ return;
+ }
+
try {
const data = await themeService.getThemePreference();
if (!isMounted) {
return;
}
+
+ if (data.theme === 'system') {
+ themeService.applyTheme('system');
+ setTheme('system');
+ return;
+ }
+
+ themeService.applyTheme(data.theme);
setTheme(data.theme);
} catch {
- // Keep next-themes default behavior (system) on failures.
+ const fallbackTheme = themeService.getInitialTheme();
+ themeService.applyTheme(fallbackTheme);
+ setTheme(fallbackTheme);
}
};
@@ -29,6 +47,7 @@ export function useTheme() {
const updateTheme = useCallback(
async (nextTheme: ThemePreference): Promise => {
+ themeService.applyTheme(nextTheme);
setTheme(nextTheme);
try {
diff --git a/services/themeService.ts b/services/themeService.ts
index 66d8447..54f1d36 100644
--- a/services/themeService.ts
+++ b/services/themeService.ts
@@ -1,6 +1,8 @@
import axios from 'axios';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
+const THEME_STORAGE_KEY = 'theme';
+const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)';
export type ThemePreference = 'light' | 'dark' | 'system';
@@ -8,11 +10,89 @@ interface ThemePreferenceResponse {
theme: ThemePreference;
}
+function isThemePreference(value: string | null): value is ThemePreference {
+ return value === 'light' || value === 'dark' || value === 'system';
+}
+
+function getSystemTheme(): 'light' | 'dark' {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return 'light';
+ }
+
+ return window.matchMedia(COLOR_SCHEME_QUERY).matches ? 'dark' : 'light';
+}
+
+function applyThemeClass(theme: 'light' | 'dark') {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const root = document.documentElement;
+ root.classList.toggle('dark', theme === 'dark');
+ root.style.colorScheme = theme;
+ root.setAttribute('data-theme', theme);
+}
+
/**
* Handles theme preference API communication.
* Hooks consume this service; UI components do not.
*/
export const themeService = {
+ getStoredThemePreference(): ThemePreference | null {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
+ return isThemePreference(storedTheme) ? storedTheme : null;
+ },
+
+ setStoredThemePreference(theme: ThemePreference) {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (theme === 'system') {
+ window.localStorage.removeItem(THEME_STORAGE_KEY);
+ return;
+ }
+
+ window.localStorage.setItem(THEME_STORAGE_KEY, theme);
+ },
+
+ getInitialTheme(): 'light' | 'dark' {
+ const storedTheme = this.getStoredThemePreference();
+ if (storedTheme === 'light' || storedTheme === 'dark') {
+ return storedTheme;
+ }
+
+ return getSystemTheme();
+ },
+
+ getThemeScript(): string {
+ return `
+ (function() {
+ const storageKey = '${THEME_STORAGE_KEY}';
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const storedTheme = window.localStorage.getItem(storageKey);
+ const isDark = storedTheme === 'dark' || (!storedTheme && mediaQuery.matches);
+ const root = document.documentElement;
+ root.classList.toggle('dark', isDark);
+ root.style.colorScheme = isDark ? 'dark' : 'light';
+ root.setAttribute('data-theme', isDark ? 'dark' : 'light');
+ })();
+ `;
+ },
+
+ applyTheme(theme: ThemePreference | 'light' | 'dark') {
+ if (theme === 'system') {
+ applyThemeClass(getSystemTheme());
+ return;
+ }
+
+ applyThemeClass(theme);
+ },
+
async getThemePreference(): Promise {
const { data } = await axios.get(
`${API_BASE_URL}/api/user/preferences/theme`
@@ -23,6 +103,8 @@ export const themeService = {
async saveThemePreference(
theme: ThemePreference
): Promise {
+ this.setStoredThemePreference(theme);
+
const { data } = await axios.post(
`${API_BASE_URL}/api/user/preferences/theme`,
{ theme }