diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe28da81..b8499280 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import i18n from './i18n' import { WalletProvider } from './context/WalletContext' import { ToastProvider } from './context/ToastContext' import { NotificationProvider } from './context/NotificationContext' +import { ThemeProvider } from './context/ThemeContext' import { NotFoundPage } from './pages/NotFoundPage' import AppShell from './components/layout/AppShell' import LandingPage from './pages/LandingPage' @@ -92,13 +93,15 @@ const App: React.FC = () => { return ( - - - - - - - + + + + + + + + + ) diff --git a/frontend/src/components/layout/TopNav.test.tsx b/frontend/src/components/layout/TopNav.test.tsx new file mode 100644 index 00000000..492610a9 --- /dev/null +++ b/frontend/src/components/layout/TopNav.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, test, expect, beforeEach, vi } from 'vitest' +import TopNav from './TopNav' +import { WalletProvider } from '../../context/WalletContext' +import { ThemeProvider } from '../../context/ThemeContext' + +describe('TopNav Theme Toggle', () => { + beforeEach(() => { + localStorage.clear() + // minimal matchMedia mock + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })), + }) + }) + + test('cycles theme and persists selection to localStorage', async () => { + render( + + + + + + + + ) + + const toggle = screen.getByRole('switch') + // default is dark + expect(localStorage.getItem('theme-mode')).toBe('dark') + + await act(async () => { fireEvent.click(toggle) }) + expect(localStorage.getItem('theme-mode')).toBe('system') + + await act(async () => { fireEvent.click(toggle) }) + expect(localStorage.getItem('theme-mode')).toBe('light') + + await act(async () => { fireEvent.click(toggle) }) + expect(localStorage.getItem('theme-mode')).toBe('dark') + }) +}) diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index 35c9dd27..0b0fda9f 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -8,6 +8,8 @@ import { NotificationCenter } from '../notifications/NotificationCenter' import { SUPPORTED_LANGUAGES } from '../../i18n/options' import type { SupportedLanguage } from '../../i18n/options' import './TopNav.css' +import useTheme from '../../hooks/useTheme' +import { Sun, Moon, Monitor } from 'lucide-react' interface TopNavProps { onMenuClick: () => void @@ -45,6 +47,7 @@ const TopNav: React.FC = ({ const bellButtonRef = useRef(null) const { t, i18n } = useTranslation() const location = useLocation() + const { mode, setMode } = useTheme() const activeLanguage = (i18n.resolvedLanguage ?? 'en') as SupportedLanguage @@ -130,6 +133,21 @@ const TopNav: React.FC = ({ /> + {/* Theme toggle: cycles light -> dark -> system */} + +
= ({ ))}
- {connected && publicKey ? ( ready ? ( <> diff --git a/frontend/src/context/ThemeContext.tsx b/frontend/src/context/ThemeContext.tsx new file mode 100644 index 00000000..73a71cc6 --- /dev/null +++ b/frontend/src/context/ThemeContext.tsx @@ -0,0 +1,77 @@ +import React, { createContext, useEffect, useState } from 'react' + +export type ThemeMode = 'light' | 'dark' | 'system' + +interface ThemeContextValue { + mode: ThemeMode + setMode: (mode: ThemeMode) => void + effectiveTheme: 'light' | 'dark' +} + +const ThemeContext = createContext({ + mode: 'dark', + setMode: () => {}, + effectiveTheme: 'dark', +}) + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [mode, setMode] = useState(() => { + try { + const stored = localStorage.getItem('theme-mode') + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored + } catch (e) { + // ignore + } + return 'dark' + }) + + const [systemPrefersDark, setSystemPrefersDark] = useState(() => { + if (typeof window === 'undefined' || !window.matchMedia) return true + return window.matchMedia('(prefers-color-scheme: dark)').matches + }) + + // Apply theme class to root and persist preference + useEffect(() => { + const effective = mode === 'system' ? (systemPrefersDark ? 'dark' : 'light') : mode + const root = document.documentElement + if (effective === 'light') root.classList.add('theme-light') + else root.classList.remove('theme-light') + + try { + localStorage.setItem('theme-mode', mode) + } catch (e) { + // ignore + } + }, [mode, systemPrefersDark]) + + // Listen to system preference changes + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return + const mql = window.matchMedia('(prefers-color-scheme: dark)') + const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches) + + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler) + } else if (typeof (mql as any).addListener === 'function') { + ;(mql as any).addListener(handler) + } + + return () => { + if (typeof mql.removeEventListener === 'function') { + mql.removeEventListener('change', handler) + } else if (typeof (mql as any).removeListener === 'function') { + ;(mql as any).removeListener(handler) + } + } + }, []) + + const effectiveTheme = mode === 'system' ? (systemPrefersDark ? 'dark' : 'light') : mode + + return ( + + {children} + + ) +} + +export default ThemeContext diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 00000000..ca877fbf --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react' +import ThemeContext from '../context/ThemeContext' + +export const useTheme = () => useContext(ThemeContext) + +export default useTheme diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 0903171a..73998584 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -201,3 +201,36 @@ input:focus, textarea:focus { padding: 12px 16px; } } + +/* Theme transition helpers */ +body, +.glass-panel, +input, +textarea, +th, +td, +.dag-node, +.chip { + transition: background-color 300ms ease, color 300ms ease, border-color 300ms ease, box-shadow 300ms ease; +} + +/* Light theme overrides */ +.theme-light { + --bg-primary: #FFFFFF; + --bg-surface: #F8FAFC; + --bg-surface-alt: #F1F5F9; + --bg-secondary: #F3F4F6; + --border-color: #E6E9EE; + --border-subtle: #E9EEF4; + --text-primary: #0A0E14; + --text-secondary: #475569; + --accent-cyan: #0EA5E9; + --accent-purple: #7C3AED; + --accent-green: #059669; + --panel-bg: var(--bg-surface); + --panel-border: var(--border-color); + --primary: var(--accent-purple); + --primary-hover: #6d28d9; + --accent: var(--accent-cyan); +} +