Skip to content

Commit d461c7f

Browse files
authored
Add dark mode toggle to header (#1667)
* feat: add dark mode toggle to header Add a theme toggle button (System/Light/Dark cycle) between the tenant selector and user menu. Persists preference to localStorage and respects system prefers-color-scheme as default. Includes inline script in index.html to prevent flash of wrong theme before React hydrates. * fix: remove unused vi import from theme toggle test * fix: add TooltipProvider to header and app-shell test wrappers ThemeToggle uses Tooltip which requires TooltipProvider in the tree. * fix: wrap theme bootstrap script in try/catch for restricted environments * fix: add test isolation reset for theme module state Expose _resetForTesting() to clear module-scoped state between tests, preventing order-dependent flakiness from shared currentTheme/listeners. * fix: add environment guards to theme resolution functions Guard window.matchMedia and document access for defensive safety in non-browser contexts. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent b5f374d commit d461c7f

7 files changed

Lines changed: 264 additions & 4 deletions

File tree

frontend/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Meridian Operations Console</title>
8+
<script>
9+
// Prevent flash of wrong theme before React hydrates
10+
try {
11+
var t = localStorage.getItem('meridian:theme');
12+
var dark = t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
13+
if (dark) document.documentElement.classList.add('dark');
14+
} catch (_) {}
15+
</script>
816
</head>
917
<body>
1018
<div id="root"></div>

frontend/src/components/layout/app-shell.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
66
import { AppShell } from '@/components/layout/app-shell'
77
import { AuthProvider } from '@/contexts/auth-context'
88
import { TenantProvider } from '@/contexts/tenant-context'
9+
import { TooltipProvider } from '@/components/ui/tooltip'
910
import { createPlatformAdminToken, createTenantUserToken } from '@/test/jwt-helpers'
1011

1112
// Mock TenantSelector to avoid dependency on ungenerated proto clients
@@ -23,9 +24,11 @@ function renderWithProviders(ui: React.ReactElement, token?: string) {
2324
<QueryClientProvider client={queryClient}>
2425
<AuthProvider initialToken={token}>
2526
<TenantProvider>
26-
<MemoryRouter>
27-
{ui}
28-
</MemoryRouter>
27+
<TooltipProvider>
28+
<MemoryRouter>
29+
{ui}
30+
</MemoryRouter>
31+
</TooltipProvider>
2932
</TenantProvider>
3033
</AuthProvider>
3134
</QueryClientProvider>

frontend/src/components/layout/header.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55
import { Header } from '@/components/layout/header'
66
import { AuthProvider } from '@/contexts/auth-context'
77
import { TenantProvider } from '@/contexts/tenant-context'
8+
import { TooltipProvider } from '@/components/ui/tooltip'
89
import { createPlatformAdminToken, createTenantUserToken } from '@/test/jwt-helpers'
910

1011
// Mock TenantSelector to avoid dependency on ungenerated proto clients
@@ -22,7 +23,9 @@ function renderWithProviders(ui: React.ReactElement, token?: string) {
2223
<QueryClientProvider client={queryClient}>
2324
<AuthProvider initialToken={token}>
2425
<TenantProvider>
25-
{ui}
26+
<TooltipProvider>
27+
{ui}
28+
</TooltipProvider>
2629
</TenantProvider>
2730
</AuthProvider>
2831
</QueryClientProvider>

frontend/src/components/layout/header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { useAuth } from '@/contexts/auth-context'
1010
import { useTenantContext } from '@/contexts/tenant-context'
1111
import { TenantSelector } from '@/components/layout/tenant-selector'
12+
import { ThemeToggle } from '@/components/layout/theme-toggle'
1213

1314
interface HeaderProps {
1415
onMenuToggle: () => void
@@ -41,6 +42,8 @@ export function Header({ onMenuToggle, sidebarOpen, sidebarId }: HeaderProps) {
4142
<div className="ml-auto flex items-center gap-4">
4243
{isPlatformAdmin && <TenantSelector />}
4344

45+
<ThemeToggle />
46+
4447
<DropdownMenu>
4548
<DropdownMenuTrigger asChild>
4649
<Button variant="ghost" size="icon" aria-label="User menu">
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, beforeEach } from "vitest"
2+
import { render, screen } from "@testing-library/react"
3+
import userEvent from "@testing-library/user-event"
4+
import { TooltipProvider } from "@/components/ui/tooltip"
5+
import { ThemeToggle } from "./theme-toggle"
6+
import { _resetForTesting } from "@/lib/use-theme"
7+
8+
function renderToggle() {
9+
return render(
10+
<TooltipProvider>
11+
<ThemeToggle />
12+
</TooltipProvider>,
13+
)
14+
}
15+
16+
describe("ThemeToggle", () => {
17+
beforeEach(() => {
18+
localStorage.clear()
19+
document.documentElement.classList.remove("dark")
20+
_resetForTesting()
21+
})
22+
23+
it("renders with system theme by default", () => {
24+
renderToggle()
25+
expect(screen.getByRole("button", { name: /theme: system/i })).toBeInTheDocument()
26+
})
27+
28+
it("cycles through themes: system -> light -> dark -> system", async () => {
29+
const user = userEvent.setup()
30+
renderToggle()
31+
32+
const button = screen.getByRole("button", { name: /theme/i })
33+
34+
// system -> light
35+
await user.click(button)
36+
expect(button).toHaveAccessibleName("Theme: Light")
37+
expect(localStorage.getItem("meridian:theme")).toBe("light")
38+
39+
// light -> dark
40+
await user.click(button)
41+
expect(button).toHaveAccessibleName("Theme: Dark")
42+
expect(localStorage.getItem("meridian:theme")).toBe("dark")
43+
expect(document.documentElement.classList.contains("dark")).toBe(true)
44+
45+
// dark -> system
46+
await user.click(button)
47+
expect(button).toHaveAccessibleName("Theme: System")
48+
expect(localStorage.getItem("meridian:theme")).toBe("system")
49+
})
50+
51+
it("applies dark class when dark theme is selected", async () => {
52+
const user = userEvent.setup()
53+
renderToggle()
54+
55+
const button = screen.getByRole("button", { name: /theme/i })
56+
57+
// system -> light -> dark
58+
await user.click(button)
59+
await user.click(button)
60+
61+
expect(document.documentElement.classList.contains("dark")).toBe(true)
62+
})
63+
64+
it("removes dark class when light theme is selected", async () => {
65+
document.documentElement.classList.add("dark")
66+
const user = userEvent.setup()
67+
renderToggle()
68+
69+
const button = screen.getByRole("button", { name: /theme/i })
70+
71+
// system -> light
72+
await user.click(button)
73+
expect(document.documentElement.classList.contains("dark")).toBe(false)
74+
})
75+
76+
it("restores theme from localStorage", () => {
77+
localStorage.setItem("meridian:theme", "dark")
78+
_resetForTesting()
79+
80+
renderToggle()
81+
82+
expect(document.documentElement.classList.contains("dark")).toBe(true)
83+
})
84+
85+
it("is keyboard accessible", async () => {
86+
const user = userEvent.setup()
87+
renderToggle()
88+
89+
await user.tab()
90+
const button = screen.getByRole("button", { name: /theme/i })
91+
expect(button).toHaveFocus()
92+
93+
await user.keyboard("{Enter}")
94+
expect(button).toHaveAccessibleName("Theme: Light")
95+
})
96+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Sun, Moon, Monitor } from "lucide-react"
2+
import { Button } from "@/components/ui/button"
3+
import {
4+
Tooltip,
5+
TooltipContent,
6+
TooltipTrigger,
7+
} from "@/components/ui/tooltip"
8+
import { useTheme, type Theme } from "@/lib/use-theme"
9+
10+
const LABELS: Record<Theme, string> = {
11+
system: "Theme: System",
12+
light: "Theme: Light",
13+
dark: "Theme: Dark",
14+
}
15+
16+
export function ThemeToggle() {
17+
const { theme, resolvedTheme, cycleTheme } = useTheme()
18+
19+
const Icon =
20+
theme === "system" ? Monitor : resolvedTheme === "dark" ? Moon : Sun
21+
22+
return (
23+
<Tooltip>
24+
<TooltipTrigger asChild>
25+
<Button
26+
variant="ghost"
27+
size="icon"
28+
aria-label={LABELS[theme]}
29+
onClick={cycleTheme}
30+
>
31+
<Icon className="size-5" />
32+
</Button>
33+
</TooltipTrigger>
34+
<TooltipContent>{LABELS[theme]}</TooltipContent>
35+
</Tooltip>
36+
)
37+
}

frontend/src/lib/use-theme.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useCallback, useEffect, useSyncExternalStore } from "react"
2+
3+
export type Theme = "system" | "light" | "dark"
4+
5+
const STORAGE_KEY = "meridian:theme"
6+
7+
function getStoredTheme(): Theme {
8+
try {
9+
const stored = localStorage.getItem(STORAGE_KEY)
10+
if (stored === "light" || stored === "dark" || stored === "system") {
11+
return stored
12+
}
13+
} catch {
14+
// localStorage unavailable (SSR, iframe sandbox, etc.)
15+
}
16+
return "system"
17+
}
18+
19+
function getResolvedTheme(theme: Theme): "light" | "dark" {
20+
if (theme === "system") {
21+
if (typeof window === "undefined") return "light"
22+
return window.matchMedia("(prefers-color-scheme: dark)").matches
23+
? "dark"
24+
: "light"
25+
}
26+
return theme
27+
}
28+
29+
function applyTheme(theme: Theme): void {
30+
if (typeof document === "undefined") return
31+
const resolved = getResolvedTheme(theme)
32+
document.documentElement.classList.toggle("dark", resolved === "dark")
33+
}
34+
35+
// Simple pub/sub so useSyncExternalStore can react to changes
36+
let listeners: Array<() => void> = []
37+
let currentTheme: Theme = getStoredTheme()
38+
39+
function subscribe(listener: () => void): () => void {
40+
listeners = [...listeners, listener]
41+
return () => {
42+
listeners = listeners.filter((l) => l !== listener)
43+
}
44+
}
45+
46+
function getSnapshot(): Theme {
47+
return currentTheme
48+
}
49+
50+
function setTheme(theme: Theme): void {
51+
currentTheme = theme
52+
try {
53+
localStorage.setItem(STORAGE_KEY, theme)
54+
} catch {
55+
// localStorage unavailable
56+
}
57+
applyTheme(theme)
58+
for (const listener of listeners) {
59+
listener()
60+
}
61+
}
62+
63+
/** Reset module state for test isolation. Not for production use. */
64+
export function _resetForTesting(): void {
65+
currentTheme = getStoredTheme()
66+
listeners = []
67+
}
68+
69+
export function useTheme() {
70+
const theme = useSyncExternalStore(subscribe, getSnapshot)
71+
72+
// Sync from localStorage on mount (covers cases where localStorage was
73+
// set before this module loaded, e.g. by the inline script in index.html)
74+
useEffect(() => {
75+
const stored = getStoredTheme()
76+
if (stored !== currentTheme) {
77+
currentTheme = stored
78+
for (const l of listeners) l()
79+
}
80+
applyTheme(currentTheme)
81+
82+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
83+
const handleChange = () => {
84+
if (currentTheme === "system") {
85+
applyTheme("system")
86+
for (const listener of listeners) {
87+
listener()
88+
}
89+
}
90+
}
91+
mediaQuery.addEventListener("change", handleChange)
92+
return () => mediaQuery.removeEventListener("change", handleChange)
93+
}, [])
94+
95+
const cycleTheme = useCallback(() => {
96+
const next: Record<Theme, Theme> = {
97+
system: "light",
98+
light: "dark",
99+
dark: "system",
100+
}
101+
setTheme(next[currentTheme])
102+
}, [])
103+
104+
return {
105+
theme,
106+
resolvedTheme: getResolvedTheme(theme),
107+
setTheme,
108+
cycleTheme,
109+
}
110+
}

0 commit comments

Comments
 (0)