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);
+}
+