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
17 changes: 10 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -92,13 +93,15 @@ const App: React.FC = () => {
return (
<I18nextProvider i18n={i18n}>
<ErrorBoundary>
<WalletProvider>
<ToastProvider>
<Router>
<AppContent />
</Router>
</ToastProvider>
</WalletProvider>
<ThemeProvider>
<WalletProvider>
<ToastProvider>
<Router>
<AppContent />
</Router>
</ToastProvider>
</WalletProvider>
</ThemeProvider>
</ErrorBoundary>
</I18nextProvider>
)
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/layout/TopNav.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<WalletProvider>
<ThemeProvider>
<TopNav onMenuClick={vi.fn()} onToggleSidebar={vi.fn()} sidebarCollapsed={false} isMobile={false} />
</ThemeProvider>
</WalletProvider>
</MemoryRouter>
)

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')
})
})
19 changes: 18 additions & 1 deletion frontend/src/components/layout/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,6 +47,7 @@ const TopNav: React.FC<TopNavProps> = ({
const bellButtonRef = useRef<HTMLButtonElement>(null)
const { t, i18n } = useTranslation()
const location = useLocation()
const { mode, setMode } = useTheme()

const activeLanguage = (i18n.resolvedLanguage ?? 'en') as SupportedLanguage

Expand Down Expand Up @@ -130,6 +133,21 @@ const TopNav: React.FC<TopNavProps> = ({
/>
</div>

{/* Theme toggle: cycles light -> dark -> system */}
<button
className="theme-toggle"
onClick={() => {
const next: Record<string, 'light' | 'dark' | 'system'> = { light: 'dark', dark: 'system', system: 'light' }
setMode(next[mode])
}}
role="switch"
aria-label={mode === 'light' ? 'Switch to dark or system theme' : mode === 'dark' ? 'Switch to system or light theme' : 'Switch to light or dark theme'}
aria-checked={mode === 'dark' ? 'true' : mode === 'light' ? 'false' : 'mixed'}
title={`Theme: ${mode}`}
>
{mode === 'light' ? <Sun size={16} /> : mode === 'dark' ? <Moon size={16} /> : <Monitor size={16} />}
</button>

<div
className="language-switcher"
id="language-switcher"
Expand All @@ -155,7 +173,6 @@ const TopNav: React.FC<TopNavProps> = ({
</button>
))}
</div>

{connected && publicKey ? (
ready ? (
<>
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/context/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeContextValue>({
mode: 'dark',
setMode: () => {},
effectiveTheme: 'dark',
})

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setMode] = useState<ThemeMode>(() => {
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<boolean>(() => {
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 (
<ThemeContext.Provider value={{ mode, setMode, effectiveTheme }}>
{children}
</ThemeContext.Provider>
)
}

export default ThemeContext
6 changes: 6 additions & 0 deletions frontend/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useContext } from 'react'
import ThemeContext from '../context/ThemeContext'

export const useTheme = () => useContext(ThemeContext)

export default useTheme
33 changes: 33 additions & 0 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}