Skip to content

Commit e26e947

Browse files
committed
stateful darkmode toggle
1 parent d1d7d83 commit e26e947

File tree

6 files changed

+170
-3
lines changed

6 files changed

+170
-3
lines changed

poliloom-gui/src/app/globals.css

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,9 @@
132132
--font-mono: var(--font-monaspace);
133133
}
134134

135+
/* Dark mode: apply when .dark class is set, or when system prefers dark and no override */
135136
@media (prefers-color-scheme: dark) {
136-
:root {
137+
:root:not(.light) {
137138
/* Backgrounds */
138139
--background: #0a0a0a;
139140
--surface: #18181b; /* zinc-900 */
@@ -192,6 +193,65 @@
192193
}
193194
}
194195

196+
/* Manual dark mode override */
197+
:root.dark {
198+
/* Backgrounds */
199+
--background: #0a0a0a;
200+
--surface: #18181b; /* zinc-900 */
201+
--surface-muted: #1f1f23;
202+
--surface-hover: #27272a; /* zinc-800 */
203+
--surface-active: #3f3f46; /* zinc-700 */
204+
205+
/* Text */
206+
--foreground: #ededed;
207+
--foreground-secondary: #d4d4d8; /* zinc-300 */
208+
--foreground-tertiary: #a1a1aa; /* zinc-400 */
209+
--foreground-muted: #71717a; /* zinc-500 */
210+
--foreground-subtle: #52525b; /* zinc-600 */
211+
212+
/* Borders */
213+
--border-muted: #27272a; /* zinc-800 */
214+
--border: #3f3f46; /* zinc-700 */
215+
--border-strong: #52525b; /* zinc-600 */
216+
217+
/* Primary/Accent (Indigo) */
218+
--accent: #6366f1; /* indigo-500 */
219+
--accent-hover: #818cf8; /* indigo-400 */
220+
--accent-foreground: #a5b4fc; /* indigo-300 */
221+
--accent-foreground-hover: #c7d2fe; /* indigo-200 */
222+
--accent-muted: rgba(99, 102, 241, 0.15);
223+
--accent-muted-hover: rgba(99, 102, 241, 0.25);
224+
--accent-light: #4f46e5; /* indigo-600 */
225+
--accent-border-hover: #6366f1; /* indigo-500 */
226+
--accent-on-muted: #c7d2fe; /* indigo-200 */
227+
--accent-on-solid: #ffffff;
228+
--accent-on-solid-muted: #a5b4fc; /* indigo-300 - dimmed text on solid */
229+
230+
/* Success (Green) */
231+
--success: #22c55e; /* green-500 */
232+
--success-bright: #22c55e; /* green-500 */
233+
--success-muted: rgba(34, 197, 94, 0.15);
234+
--success-muted-hover: rgba(34, 197, 94, 0.25);
235+
--success-foreground: #86efac; /* green-300 */
236+
237+
/* Danger (Red) */
238+
--danger: #ef4444; /* red-500 */
239+
--danger-bright: #f87171; /* red-400 */
240+
--danger-muted: rgba(239, 68, 68, 0.15);
241+
--danger-muted-hover: rgba(239, 68, 68, 0.25);
242+
--danger-foreground: #fca5a5; /* red-300 */
243+
--danger-foreground-hover: #fecaca; /* red-200 */
244+
--danger-subtle: #fca5a5; /* red-300 */
245+
--danger-deep: rgba(127, 29, 29, 0.8);
246+
247+
/* Info (Blue) */
248+
--info: #3b82f6; /* blue-500 */
249+
--info-hover: #60a5fa; /* blue-400 */
250+
--info-muted: rgba(59, 130, 246, 0.15);
251+
--info-muted-hover: rgba(59, 130, 246, 0.25);
252+
--info-foreground: #93c5fd; /* blue-300 */
253+
}
254+
195255
body {
196256
background: var(--background);
197257
color: var(--foreground);

poliloom-gui/src/app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from 'next'
22
import Script from 'next/script'
3+
import { cookies } from 'next/headers'
34
import './globals.css'
45
import { auth } from '@/auth'
56
import { SessionProvider } from '@/components/SessionProvider'
@@ -27,9 +28,12 @@ export default async function RootLayout({
2728
children: React.ReactNode
2829
}>) {
2930
const session = await auth()
31+
const cookieStore = await cookies()
32+
const themeCookie = cookieStore.get('poliloom_theme')?.value
33+
const themeClass = themeCookie === 'light' || themeCookie === 'dark' ? themeCookie : ''
3034

3135
return (
32-
<html lang="en">
36+
<html lang="en" className={themeClass}>
3337
<head>
3438
<Script
3539
src="https://cloud.umami.is/script.js"

poliloom-gui/src/components/layout/Header.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
'use client'
22

3-
import { useState, useEffect } from 'react'
3+
import { useState, useEffect, useSyncExternalStore } from 'react'
44
import { useSession, signOut, signIn } from 'next-auth/react'
55
import Image from 'next/image'
66
import Link from 'next/link'
77
import { Button } from '@/components/ui/Button'
88
import { SpinningCounter } from '@/components/ui/SpinningCounter'
99
import { useEvaluationCount } from '@/contexts/EvaluationCountContext'
10+
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
1011

1112
export function Header() {
1213
const { status } = useSession()
1314
const [menuOpen, setMenuOpen] = useState(false)
1415
const { evaluationCount } = useEvaluationCount()
16+
const { theme, setTheme } = useUserPreferences()
17+
const systemTheme = useSyncExternalStore(
18+
(cb) => {
19+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
20+
mq.addEventListener('change', cb)
21+
return () => mq.removeEventListener('change', cb)
22+
},
23+
() => (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'),
24+
() => 'light',
25+
)
26+
const resolvedTheme = theme ?? systemTheme
27+
28+
const toggleTheme = () => {
29+
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
30+
}
1531

1632
useEffect(() => {
1733
if (!menuOpen) return
@@ -71,6 +87,33 @@ export function Header() {
7187
/>
7288
</Button>
7389
)}
90+
<Button
91+
onClick={toggleTheme}
92+
variant="secondary"
93+
size="small"
94+
title={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
95+
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
96+
>
97+
{resolvedTheme === 'dark' ? (
98+
<svg
99+
xmlns="http://www.w3.org/2000/svg"
100+
viewBox="0 0 24 24"
101+
fill="currentColor"
102+
className="w-4 h-4"
103+
>
104+
<path d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
105+
</svg>
106+
) : (
107+
<svg
108+
xmlns="http://www.w3.org/2000/svg"
109+
viewBox="0 0 24 24"
110+
fill="currentColor"
111+
className="w-4 h-4"
112+
>
113+
<path d="M12 2.25a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM7.5 12a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM18.894 6.166a.75.75 0 0 0-1.06-1.06l-1.591 1.59a.75.75 0 1 0 1.06 1.061l1.591-1.59ZM21.75 12a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1 0-1.5H21a.75.75 0 0 1 .75.75ZM17.834 18.894a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 1 0-1.061 1.06l1.591 1.591ZM12 18a.75.75 0 0 1 .75.75V21a.75.75 0 0 1-1.5 0v-2.25A.75.75 0 0 1 12 18ZM7.758 17.303a.75.75 0 0 0-1.061-1.06l-1.591 1.59a.75.75 0 0 0 1.06 1.061l1.591-1.59ZM6 12a.75.75 0 0 1-.75.75H3a.75.75 0 0 1 0-1.5h2.25A.75.75 0 0 1 6 12ZM6.697 7.757a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 0 0-1.061 1.06l1.59 1.591Z" />
114+
</svg>
115+
)}
116+
</Button>
74117
{status === 'authenticated' ? (
75118
<Button
76119
onClick={() => signOut({ callbackUrl: '/' })}

poliloom-gui/src/contexts/UserPreferencesContext.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ describe('UserPreferencesContext', () => {
4242
value: ['en-US', 'en'],
4343
})
4444

45+
// Mock window.matchMedia for theme detection
46+
Object.defineProperty(window, 'matchMedia', {
47+
writable: true,
48+
value: vi.fn().mockImplementation((query: string) => ({
49+
matches: false,
50+
media: query,
51+
onchange: null,
52+
addListener: vi.fn(),
53+
removeListener: vi.fn(),
54+
addEventListener: vi.fn(),
55+
removeEventListener: vi.fn(),
56+
dispatchEvent: vi.fn(),
57+
})),
58+
})
59+
4560
// Mock fetch
4661
fetchMock = vi.fn<typeof fetch>()
4762
global.fetch = fetchMock as typeof fetch

poliloom-gui/src/contexts/UserPreferencesContext.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
WikidataEntity,
1717
} from '@/types'
1818

19+
export type Theme = 'light' | 'dark' | undefined
20+
1921
interface UserPreferencesContextType {
2022
filters: PreferenceResponse[]
2123
languages: LanguageResponse[]
@@ -26,6 +28,8 @@ interface UserPreferencesContextType {
2628
updateFilters: (type: PreferenceType, items: WikidataEntity[]) => void
2729
isAdvancedMode: boolean
2830
setAdvancedMode: (enabled: boolean) => void
31+
theme: Theme
32+
setTheme: (theme: 'light' | 'dark') => void
2933
}
3034

3135
export const UserPreferencesContext = createContext<UserPreferencesContextType | undefined>(
@@ -34,6 +38,7 @@ export const UserPreferencesContext = createContext<UserPreferencesContextType |
3438

3539
const FILTERS_STORAGE_KEY = 'poliloom_evaluation_filters'
3640
const ADVANCED_MODE_KEY = 'poliloom_advanced_mode'
41+
const THEME_COOKIE_NAME = 'poliloom_theme'
3742

3843
// Advanced mode uses useSyncExternalStore for SSR safety
3944
function getAdvancedModeSnapshot(): boolean {
@@ -50,6 +55,33 @@ function subscribeToAdvancedMode(callback: () => void): () => void {
5055
return () => window.removeEventListener('storage', callback)
5156
}
5257

58+
// Theme helpers
59+
function getThemeCookie(): Theme {
60+
if (typeof document === 'undefined') return undefined
61+
const match = document.cookie.match(new RegExp(`${THEME_COOKIE_NAME}=([^;]+)`))
62+
const value = match?.[1]
63+
if (value === 'light' || value === 'dark') return value
64+
return undefined
65+
}
66+
67+
function subscribeToTheme(callback: () => void): () => void {
68+
window.addEventListener('storage', callback)
69+
return () => window.removeEventListener('storage', callback)
70+
}
71+
72+
function applyThemeToDocument(theme: 'light' | 'dark') {
73+
if (typeof document === 'undefined') return
74+
const root = document.documentElement
75+
root.classList.remove('light', 'dark')
76+
root.classList.add(theme)
77+
}
78+
79+
function setThemeCookie(theme: 'light' | 'dark') {
80+
if (typeof document === 'undefined') return
81+
const maxAge = 365 * 24 * 60 * 60 // 1 year
82+
document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${maxAge}; SameSite=Lax`
83+
}
84+
5385
// Helper function to detect browser language and match with available languages
5486
const detectBrowserLanguage = (availableLanguages: LanguageResponse[]): WikidataEntity[] => {
5587
try {
@@ -109,6 +141,15 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
109141
forceUpdate((n) => n + 1)
110142
}, [])
111143

144+
// Theme state
145+
const theme = useSyncExternalStore(subscribeToTheme, getThemeCookie, () => undefined)
146+
147+
const setTheme = useCallback((newTheme: 'light' | 'dark') => {
148+
setThemeCookie(newTheme)
149+
applyThemeToDocument(newTheme)
150+
forceUpdate((n) => n + 1)
151+
}, [])
152+
112153
// Fetch available languages
113154
useEffect(() => {
114155
const fetchLanguages = async () => {
@@ -218,6 +259,8 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
218259
updateFilters,
219260
isAdvancedMode,
220261
setAdvancedMode,
262+
theme,
263+
setTheme,
221264
}
222265

223266
return <UserPreferencesContext.Provider value={value}>{children}</UserPreferencesContext.Provider>

poliloom-gui/src/test/test-utils.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const MockUserPreferencesProvider = ({ children }: { children: React.ReactNode }
6363
updateFilters: vi.fn(),
6464
isAdvancedMode: false,
6565
setAdvancedMode: vi.fn(),
66+
theme: undefined,
67+
setTheme: vi.fn(),
6668
}}
6769
>
6870
{children}

0 commit comments

Comments
 (0)