Skip to content
Open
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
33 changes: 25 additions & 8 deletions src/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from 'lucide-react';
import { UserProfile } from '../types';
import { fetchWithAuth } from '../lib/auth';
import { applyTheme, getStoredTheme } from '../lib/theme';
import { applyTheme, getStoredTheme, previewTheme, revertThemePreview, ThemeMode } from '../lib/theme';
import { logoutUser } from '../lib/auth';

interface SettingsViewProps {
Expand Down Expand Up @@ -52,7 +52,7 @@ export default function SettingsView({ user, onUpdateUser }: SettingsViewProps)
const [email, setEmail] = useState(user?.email);

// Appearance
const [appearance, setAppearance] = useState<'light' | 'dark' | 'system'>(() => user?.appearance || getStoredTheme());
const [appearance, setAppearance] = useState<ThemeMode>(() => user?.appearance || getStoredTheme());

// Security & 2FA State
const [currentPassword, setCurrentPassword] = useState('');
Expand Down Expand Up @@ -246,8 +246,8 @@ export default function SettingsView({ user, onUpdateUser }: SettingsViewProps)
if (user) {
setName(user.name || '');
setEmail(user.email || '');
setAppearance(user.appearance || 'system');
applyTheme(user.appearance || 'system');
setAppearance(user.appearance || getStoredTheme());
applyTheme(user.appearance || getStoredTheme());
setTwoFactorEnabled(user.twoFactorEnabled || false);
setGeminiKey(user.apiKeys?.gemini || '');
setOpenaiKey(user.apiKeys?.openai || '');
Expand All @@ -260,10 +260,16 @@ export default function SettingsView({ user, onUpdateUser }: SettingsViewProps)
setAllowTelemetry(user.privacy?.allowTelemetry ?? true);
setSearchHistoryCleared(user.privacy?.searchHistoryCleared ?? false);
} else {
applyTheme(getStoredTheme());
revertThemePreview();
}
}, [user]);

useEffect(() => {
return () => {
revertThemePreview();
};
}, []);

const handleExportData = async () => {
setIsExporting(true);
setExportSuccess(null);
Expand Down Expand Up @@ -356,9 +362,19 @@ export default function SettingsView({ user, onUpdateUser }: SettingsViewProps)
}
};

const handleAppearanceChange = (mode: 'light' | 'dark' | 'system') => {
const committedAppearance: ThemeMode = user?.appearance || getStoredTheme();

const handleSettingsTabChange = (id: typeof activeTab) => {
if (activeTab === 'appearance' && id !== 'appearance') {
setAppearance(committedAppearance);
previewTheme(committedAppearance);
}
setActiveTab(id);
};

const handleAppearanceChange = (mode: ThemeMode) => {
setAppearance(mode);
applyTheme(mode);
previewTheme(mode);
};

const handleChangePassword = async (e: React.FormEvent) => {
Expand Down Expand Up @@ -543,6 +559,7 @@ export default function SettingsView({ user, onUpdateUser }: SettingsViewProps)
}
});

applyTheme(appearance);
setSavedSuccess(true);
setTimeout(() => setSavedSuccess(false), 3000);
} catch (err: any) {
Expand Down Expand Up @@ -598,7 +615,7 @@ export default function SettingsView({ user, onUpdateUser }: SettingsViewProps)
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id as any)}
onClick={() => handleSettingsTabChange(item.id as typeof activeTab)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all cursor-pointer text-left ${
isActive
? 'bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-white font-semibold'
Expand Down
143 changes: 143 additions & 0 deletions src/lib/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';

function installThemeTestDom(options?: { prefersDark?: boolean }) {
const store: Record<string, string> = {};
const classes = new Set<string>();
const prefersDark = options?.prefersDark ?? false;
const changeListeners: Array<(event: { matches: boolean }) => void> = [];

const mediaQuery = {
matches: prefersDark,
addEventListener: (_event: string, handler: (event: { matches: boolean }) => void) => {
changeListeners.push(handler);
},
removeEventListener: () => {},
dispatch(matches: boolean) {
mediaQuery.matches = matches;
for (const handler of changeListeners) {
handler({ matches });
}
},
};

const localStorageMock = {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
Object.keys(store).forEach((key) => delete store[key]);
},
};

const classList = {
add: (cls: string) => {
classes.add(cls);
},
remove: (cls: string) => {
classes.delete(cls);
},
contains: (cls: string) => classes.has(cls),
};

(globalThis as any).localStorage = localStorageMock;
(globalThis as any).document = {
documentElement: { classList },
};
(globalThis as any).window = {
matchMedia: (query: string) =>
query.includes('prefers-color-scheme: dark')
? mediaQuery
: { matches: false, addEventListener: () => {}, removeEventListener: () => {} },
};

return { store, classes, mediaQuery };
}

describe('theme preview vs persist', () => {
beforeEach(() => {
installThemeTestDom();
});

it('previewTheme applies dark class without writing localStorage', async () => {
const { store, classes } = installThemeTestDom();
const { previewTheme, getStoredTheme } = await import('./theme.ts');

previewTheme('dark');

assert.equal(classes.has('dark'), true);
assert.equal(store.theme, undefined);
assert.equal(getStoredTheme(), 'light');
});

it('applyTheme persists the mode and applies it', async () => {
const { store, classes } = installThemeTestDom();
const { applyTheme, getStoredTheme } = await import('./theme.ts');

applyTheme('dark');

assert.equal(store.theme, 'dark');
assert.equal(getStoredTheme(), 'dark');
assert.equal(classes.has('dark'), true);
});

it('revertThemePreview restores the last saved theme after an unsaved preview', async () => {
const { store, classes } = installThemeTestDom();
const { applyTheme, previewTheme, revertThemePreview, getStoredTheme } = await import('./theme.ts');

applyTheme('light');
previewTheme('dark');
assert.equal(classes.has('dark'), true);
assert.equal(getStoredTheme(), 'light');

revertThemePreview();

assert.equal(store.theme, 'light');
assert.equal(classes.has('dark'), false);
assert.equal(getStoredTheme(), 'light');
});

it('previewing system theme does not commit until applyTheme', async () => {
const { store, classes, mediaQuery } = installThemeTestDom({ prefersDark: true });
const { applyTheme, previewTheme, initTheme, getStoredTheme } = await import('./theme.ts');

initTheme();
applyTheme('light');
previewTheme('system');

assert.equal(classes.has('dark'), true);
assert.equal(store.theme, 'light');
assert.equal(getStoredTheme(), 'light');

mediaQuery.dispatch(false);
assert.equal(classes.has('dark'), false, 'unsaved system preview should follow OS light');
assert.equal(store.theme, 'light');

mediaQuery.dispatch(true);
assert.equal(classes.has('dark'), true, 'unsaved system preview should follow OS dark');
assert.equal(store.theme, 'light');
});

it('OS color-scheme changes ignore the listener when the active preview is not system', async () => {
const { store, classes, mediaQuery } = installThemeTestDom({ prefersDark: false });
const { applyTheme, previewTheme, initTheme, revertThemePreview } = await import('./theme.ts');

initTheme();
applyTheme('system');
previewTheme('dark');

mediaQuery.dispatch(false);
assert.equal(classes.has('dark'), true, 'unsaved dark preview should ignore OS going light');
assert.equal(store.theme, 'system');

revertThemePreview();
assert.equal(classes.has('dark'), false);

mediaQuery.dispatch(true);
assert.equal(classes.has('dark'), true, 'reverted system theme should follow OS again');
});
});
40 changes: 27 additions & 13 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export type ThemeMode = 'light' | 'dark' | 'system';

/** Currently applied theme, including unsaved previews. */
let activeThemeMode: ThemeMode = 'light';

export function getStoredTheme(): ThemeMode {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark' || stored === 'system') {
Expand All @@ -8,31 +11,42 @@ export function getStoredTheme(): ThemeMode {
return 'light'; // Default to light mode as requested
}

export function applyTheme(mode: ThemeMode) {
localStorage.setItem('theme', mode);
const isDark =
function isDarkMode(mode: ThemeMode): boolean {
return (
mode === 'dark' ||
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
);
}

if (isDark) {
/** Apply a theme to the document without persisting it. */
export function previewTheme(mode: ThemeMode) {
activeThemeMode = mode;
if (isDarkMode(mode)) {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}

/** Persist the theme and apply it. */
export function applyTheme(mode: ThemeMode) {
localStorage.setItem('theme', mode);
previewTheme(mode);
}

/** Restore the last saved theme (localStorage / default). */
export function revertThemePreview() {
previewTheme(getStoredTheme());
}

export function initTheme() {
const current = getStoredTheme();
applyTheme(current);

// Listen for system theme changes if set to 'system'
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (getStoredTheme() === 'system') {
if (e.matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Follow OS changes only while the active (possibly previewed) mode is system.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (activeThemeMode === 'system') {
previewTheme('system');
}
});
}