@@ -242,45 +236,24 @@ describe("App", () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetActiveAccount.mockReturnValue(null);
+ window.localStorage.clear();
});
+ const renderApp = () =>
+ render(
+
+
+
+ );
+
it("renders with FluentProvider and MainLayout", () => {
- render(
);
+ renderApp();
expect(screen.getByTestId("main-layout")).toBeInTheDocument();
expect(screen.getByTestId("home-view")).toBeInTheDocument();
});
- it("starts in dark mode", () => {
- render(
);
- expect(screen.getByTestId("main-layout")).toHaveAttribute(
- "data-dark-mode",
- "true"
- );
- });
-
- it("toggles theme when onToggleTheme is called", () => {
- render(
);
-
- expect(screen.getByTestId("main-layout")).toHaveAttribute(
- "data-dark-mode",
- "true"
- );
-
- fireEvent.click(screen.getByTestId("toggle-theme"));
- expect(screen.getByTestId("main-layout")).toHaveAttribute(
- "data-dark-mode",
- "false"
- );
-
- fireEvent.click(screen.getByTestId("toggle-theme"));
- expect(screen.getByTestId("main-layout")).toHaveAttribute(
- "data-dark-mode",
- "true"
- );
- });
-
it("starts in home view", () => {
- render(
);
+ renderApp();
expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
@@ -290,7 +263,7 @@ describe("App", () => {
});
it("switches to chat view", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-chat"));
@@ -302,7 +275,7 @@ describe("App", () => {
});
it("switches to config view", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-config"));
@@ -314,7 +287,7 @@ describe("App", () => {
});
it("switches back to chat from config", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-config"));
expect(screen.getByTestId("target-config")).toBeInTheDocument();
@@ -324,7 +297,7 @@ describe("App", () => {
});
it("sets conversationId from chat window", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-chat"));
expect(screen.getByTestId("conversation-id")).toHaveTextContent("none");
@@ -334,7 +307,7 @@ describe("App", () => {
});
it("clears conversationId on new attack", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-chat"));
fireEvent.click(screen.getByTestId("set-conversation"));
@@ -345,7 +318,7 @@ describe("App", () => {
});
it("sets active target from config page and passes to chat", () => {
- render(
);
+ renderApp();
// Switch to chat and confirm no target initially
fireEvent.click(screen.getByTestId("nav-chat"));
@@ -361,7 +334,7 @@ describe("App", () => {
});
it("switches to history view", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-history"));
@@ -374,7 +347,7 @@ describe("App", () => {
it("opens attack from history and switches to chat", async () => {
mockGetAttack.mockResolvedValue({ attack_result_id: "ar-attack-1", conversation_id: "attack-conv-1", labels: { operator: "roakey" } });
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-history"));
fireEvent.click(screen.getByTestId("open-attack"));
@@ -393,7 +366,7 @@ describe("App", () => {
conversation_id: "home-conv-1",
labels: { operator: "roakey" },
});
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("home-open-attack"));
@@ -406,7 +379,7 @@ describe("App", () => {
});
it("navigates to config from the home view", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("home-go-config"));
@@ -419,7 +392,7 @@ describe("App", () => {
it("handles failed attack open gracefully", async () => {
mockGetAttack.mockRejectedValue(new Error("Not found"));
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-history"));
fireEvent.click(screen.getByTestId("open-attack"));
@@ -445,7 +418,7 @@ describe("App", () => {
() => new Promise((resolve) => { resolveGetAttack = resolve })
);
- render(
);
+ renderApp();
// Simulate: user is already on attack A with a branched conv selected.
fireEvent.click(screen.getByTestId("nav-chat"));
@@ -489,7 +462,7 @@ describe("App", () => {
default_labels: { operator: "default_user", custom: "value" },
});
- render(
);
+ renderApp();
// The version API is called on mount and labels get merged
await waitFor(() => {
@@ -512,7 +485,7 @@ describe("App", () => {
default_labels: { custom: "value" },
});
- render(
);
+ renderApp();
// Home receives the same labels prop — assert there to avoid racing the
// async initLabels effect against a view-change re-render.
@@ -530,7 +503,7 @@ describe("App", () => {
default_labels: { operator: "backend_user", custom: "value" },
});
- render(
);
+ renderApp();
await waitFor(() => {
const labels = screen.getByTestId("home-labels-json").textContent ?? "";
@@ -540,7 +513,7 @@ describe("App", () => {
});
it("stores attack target when conversation is created with active target", () => {
- render(
);
+ renderApp();
// Set a target first
fireEvent.click(screen.getByTestId("nav-config"));
@@ -553,7 +526,7 @@ describe("App", () => {
});
it("sets active conversation when onSelectConversation is called", () => {
- render(
);
+ renderApp();
fireEvent.click(screen.getByTestId("nav-chat"));
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 73b05f82e2..7f8778efd8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect } from 'react'
-import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'
import { useMsal } from '@azure/msal-react'
import MainLayout from './components/Layout/MainLayout'
import ChatWindow from './components/Chat/ChatWindow'
@@ -42,7 +41,6 @@ function ConnectionBannerContainer() {
function App() {
const { instance } = useMsal()
- const [isDarkMode, setIsDarkMode] = useState(true)
const [currentView, setCurrentView] = useState
('home')
const [activeTarget, setActiveTarget] = useState(null)
const [globalLabels, setGlobalLabels] = useState>({ ...DEFAULT_GLOBAL_LABELS })
@@ -177,20 +175,14 @@ function App() {
}
}, [attackResultId, clearAttackState])
- const toggleTheme = () => {
- setIsDarkMode(!isDarkMode)
- }
-
return (
-
+ <>
{currentView === 'home' && (
)}
-
+ >
)
diff --git a/frontend/src/components/Layout/MainLayout.test.tsx b/frontend/src/components/Layout/MainLayout.test.tsx
index e4714d73a8..86eb8c404d 100644
--- a/frontend/src/components/Layout/MainLayout.test.tsx
+++ b/frontend/src/components/Layout/MainLayout.test.tsx
@@ -17,19 +17,14 @@ jest.mock("../../services/api", () => ({
// Mock Navigation to simplify testing
jest.mock("../Sidebar/Navigation", () => {
const MockNavigation = ({
- onToggleTheme,
- isDarkMode,
currentView,
onNavigate,
}: {
- onToggleTheme: () => void;
- isDarkMode: boolean;
currentView: string;
onNavigate: (view: string) => void;
}) => {
return (
-
-
+
);
@@ -55,8 +50,6 @@ describe("MainLayout", () => {
});
const defaultProps = {
- onToggleTheme: jest.fn(),
- isDarkMode: false,
currentView: 'chat' as const,
onNavigate: jest.fn(),
};
@@ -145,21 +138,4 @@ describe("MainLayout", () => {
expect(mockedVersionApi.getVersion).toHaveBeenCalled();
});
});
-
- it("passes theme props to Navigation", async () => {
- mockedVersionApi.getVersion.mockResolvedValue({ version: "1.0.0" });
-
- renderWithProvider(
-
- Content
-
- );
-
- const navigation = screen.getByTestId("navigation");
- expect(navigation).toHaveAttribute("data-dark-mode", "true");
-
- await waitFor(() => {
- expect(mockedVersionApi.getVersion).toHaveBeenCalled();
- });
- });
});
diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx
index ae8b8bd510..0e82c74fd4 100644
--- a/frontend/src/components/Layout/MainLayout.tsx
+++ b/frontend/src/components/Layout/MainLayout.tsx
@@ -12,16 +12,12 @@ interface MainLayoutProps {
children: React.ReactNode
currentView: ViewName
onNavigate: (view: ViewName) => void
- onToggleTheme: () => void
- isDarkMode: boolean
}
export default function MainLayout({
children,
currentView,
onNavigate,
- onToggleTheme,
- isDarkMode,
}: MainLayoutProps) {
const styles = useMainLayoutStyles()
const [version, setVersion] = useState
('Loading...')
@@ -55,8 +51,6 @@ export default function MainLayout({
{children}
diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx
index aa4c46a5b6..1263a8ba84 100644
--- a/frontend/src/components/Sidebar/Navigation.test.tsx
+++ b/frontend/src/components/Sidebar/Navigation.test.tsx
@@ -3,130 +3,150 @@
* Licensed under the MIT license.
*/
-import { render, screen, fireEvent } from "@testing-library/react";
-import { FluentProvider, webLightTheme } from "@fluentui/react-components";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ThemeProvider, useTheme } from "../../hooks/useTheme";
import Navigation from "./Navigation";
-const renderWithProvider = (ui: React.ReactElement) => {
- return render({ui});
-};
+const STORAGE_KEY = "pyrit.themeMode";
+
+const renderWithProvider = (ui: React.ReactElement) =>
+ render({ui});
describe("Navigation", () => {
const defaultProps = {
currentView: "chat" as const,
onNavigate: jest.fn(),
- onToggleTheme: jest.fn(),
- isDarkMode: false,
};
beforeEach(() => {
jest.clearAllMocks();
+ window.localStorage.clear();
+ document.documentElement.removeAttribute("data-theme");
+ document.documentElement.style.removeProperty("color-scheme");
});
it("renders the home button", () => {
renderWithProvider();
-
- const homeButton = screen.getByTitle("Home");
- expect(homeButton).toBeInTheDocument();
- expect(homeButton).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
});
- it("calls onNavigate with 'home' when home button is clicked", () => {
+ it("calls onNavigate with 'home' when home button is clicked", async () => {
+ const user = userEvent.setup();
const onNavigate = jest.fn();
renderWithProvider(
);
- fireEvent.click(screen.getByTitle("Home"));
+ await user.click(screen.getByRole("button", { name: "Home" }));
expect(onNavigate).toHaveBeenCalledWith("home");
});
it("renders the chat button", () => {
renderWithProvider();
-
- const chatButton = screen.getByTitle("Chat");
- expect(chatButton).toBeInTheDocument();
- expect(chatButton).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
});
it("renders the configuration button", () => {
renderWithProvider();
-
- const configButton = screen.getByTitle("Configuration");
- expect(configButton).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Configuration" })
+ ).toBeInTheDocument();
});
- it("calls onNavigate with 'chat' when chat button is clicked", () => {
+ it("calls onNavigate with 'chat' when chat button is clicked", async () => {
+ const user = userEvent.setup();
const onNavigate = jest.fn();
renderWithProvider(
);
- fireEvent.click(screen.getByTitle("Chat"));
+ await user.click(screen.getByRole("button", { name: "Chat" }));
expect(onNavigate).toHaveBeenCalledWith("chat");
});
- it("calls onNavigate with 'config' when config button is clicked", () => {
+ it("calls onNavigate with 'config' when config button is clicked", async () => {
+ const user = userEvent.setup();
const onNavigate = jest.fn();
renderWithProvider(
);
- fireEvent.click(screen.getByTitle("Configuration"));
+ await user.click(screen.getByRole("button", { name: "Configuration" }));
expect(onNavigate).toHaveBeenCalledWith("config");
});
it("renders the attack history button", () => {
renderWithProvider();
-
- const historyButton = screen.getByTitle("Attack History");
- expect(historyButton).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Attack History" })
+ ).toBeInTheDocument();
});
- it("calls onNavigate with 'history' when history button is clicked", () => {
+ it("calls onNavigate with 'history' when history button is clicked", async () => {
+ const user = userEvent.setup();
const onNavigate = jest.fn();
renderWithProvider(
);
- fireEvent.click(screen.getByTitle("Attack History"));
+ await user.click(screen.getByRole("button", { name: "Attack History" }));
expect(onNavigate).toHaveBeenCalledWith("history");
});
- it("renders theme toggle button with light mode title when in dark mode", () => {
- renderWithProvider(
-
- );
-
- const themeButton = screen.getByTitle("Light Mode");
- expect(themeButton).toBeInTheDocument();
+ it("renders the theme picker labelled with the current mode", () => {
+ renderWithProvider();
+ expect(
+ screen.getByRole("button", { name: "Theme: System" })
+ ).toBeInTheDocument();
});
- it("renders theme toggle button with dark mode title when in light mode", () => {
- renderWithProvider(
-
- );
+ it("opens the theme menu and exposes all three modes", async () => {
+ const user = userEvent.setup();
+ renderWithProvider();
- const themeButton = screen.getByTitle("Dark Mode");
- expect(themeButton).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Theme: System" }));
+
+ expect(
+ screen.getByRole("menuitemradio", { name: "System" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitemradio", { name: "Light" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitemradio", { name: "Dark" })
+ ).toBeInTheDocument();
});
- it("calls onToggleTheme when theme button is clicked", () => {
- const mockToggleTheme = jest.fn();
- renderWithProvider(
-
+ it("changes the theme mode when a menu item is selected", async () => {
+ const user = userEvent.setup();
+
+ function Reader() {
+ const { mode } = useTheme();
+ return {mode};
+ }
+
+ render(
+
+
+
+
);
- const themeButton = screen.getByTitle("Dark Mode");
- fireEvent.click(themeButton);
+ expect(screen.getByTestId("mode")).toHaveTextContent("system");
- expect(mockToggleTheme).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Theme: System" }));
+ await user.click(screen.getByRole("menuitemradio", { name: "Dark" }));
+
+ expect(screen.getByTestId("mode")).toHaveTextContent("dark");
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe("dark");
});
- it("theme button is not disabled", () => {
+ it("reflects the persisted mode in the trigger label", () => {
+ window.localStorage.setItem(STORAGE_KEY, "light");
renderWithProvider();
-
- const themeButton = screen.getByTitle("Dark Mode");
- expect(themeButton).not.toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "Theme: Light" })
+ ).toBeInTheDocument();
});
});
diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx
index f323272b90..8ab101644a 100644
--- a/frontend/src/components/Sidebar/Navigation.tsx
+++ b/frontend/src/components/Sidebar/Navigation.tsx
@@ -1,6 +1,12 @@
import {
Button,
+ Menu,
+ MenuItemRadio,
+ MenuList,
+ MenuPopover,
+ MenuTrigger,
} from '@fluentui/react-components'
+import type { MenuCheckedValueChangeData, MenuCheckedValueChangeEvent } from '@fluentui/react-components'
import {
ChatRegular,
HomeRegular,
@@ -9,6 +15,8 @@ import {
WeatherMoonRegular,
WeatherSunnyRegular,
} from '@fluentui/react-icons'
+import { useTheme } from '../../hooks/useTheme'
+import type { ThemeMode } from '../../hooks/useTheme'
import { useNavigationStyles } from './Navigation.styles'
export type ViewName = 'home' | 'chat' | 'history' | 'config'
@@ -16,12 +24,32 @@ export type ViewName = 'home' | 'chat' | 'history' | 'config'
interface NavigationProps {
currentView: ViewName
onNavigate: (view: ViewName) => void
- onToggleTheme: () => void
- isDarkMode: boolean
}
-export default function Navigation({ currentView, onNavigate, onToggleTheme, isDarkMode }: NavigationProps) {
+const THEME_MENU_NAME = 'theme'
+
+const THEME_LABELS: Record = {
+ system: 'System',
+ light: 'Light',
+ dark: 'Dark',
+}
+
+export default function Navigation({ currentView, onNavigate }: NavigationProps) {
const styles = useNavigationStyles()
+ const { mode, resolved, setMode } = useTheme()
+
+ const handleThemeChange = (
+ _: MenuCheckedValueChangeEvent,
+ data: MenuCheckedValueChangeData,
+ ) => {
+ const next = data.checkedItems[0]
+ if (next === 'system' || next === 'light' || next === 'dark') {
+ setMode(next)
+ }
+ }
+
+ const triggerIcon = resolved === 'dark' ? :
+ const triggerLabel = `Theme: ${THEME_LABELS[mode]}`
return (
@@ -67,14 +95,33 @@ export default function Navigation({ currentView, onNavigate, onToggleTheme, isD
-
:
}
- onClick={onToggleTheme}
- title={isDarkMode ? 'Light Mode' : 'Dark Mode'}
- aria-label={isDarkMode ? 'Light Mode' : 'Dark Mode'}
- />
+
)
}
diff --git a/frontend/src/hooks/useTheme.test.tsx b/frontend/src/hooks/useTheme.test.tsx
new file mode 100644
index 0000000000..d54d8af41b
--- /dev/null
+++ b/frontend/src/hooks/useTheme.test.tsx
@@ -0,0 +1,255 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT license.
+ */
+
+import { act, render, renderHook, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { ReactNode } from 'react'
+import { ThemeProvider, resolveTheme, useTheme } from './useTheme'
+import type { ThemeMode } from './useTheme'
+
+const STORAGE_KEY = 'pyrit.themeMode'
+const PREFERS_DARK_QUERY = '(prefers-color-scheme: dark)'
+
+type MediaListener = (event: MediaQueryListEvent) => void
+
+interface MockMediaQueryList {
+ matches: boolean
+ media: string
+ onchange: MediaListener | null
+ addListener: jest.Mock
+ removeListener: jest.Mock
+ addEventListener: jest.Mock
+ removeEventListener: jest.Mock
+ dispatchEvent: jest.Mock
+}
+
+interface MediaController {
+ setMatches: (query: string, matches: boolean) => void
+ trigger: (query: string, matches: boolean) => void
+ reset: () => void
+}
+
+// Installs a programmable matchMedia mock for the duration of a test.
+function installMatchMediaMock(): MediaController {
+ const state = new Map()
+ const listeners = new Map()
+
+ function getOrCreate(query: string): MockMediaQueryList {
+ let mql = state.get(query)
+ if (mql) return mql
+ const queryListeners: MediaListener[] = []
+ listeners.set(query, queryListeners)
+ mql = {
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn((event: string, handler: MediaListener) => {
+ if (event === 'change') queryListeners.push(handler)
+ }),
+ removeEventListener: jest.fn((event: string, handler: MediaListener) => {
+ if (event !== 'change') return
+ const idx = queryListeners.indexOf(handler)
+ if (idx >= 0) queryListeners.splice(idx, 1)
+ }),
+ dispatchEvent: jest.fn(),
+ }
+ state.set(query, mql)
+ return mql
+ }
+
+ ;(window.matchMedia as jest.Mock).mockImplementation((query: string) => getOrCreate(query))
+
+ return {
+ setMatches(query, matches) {
+ getOrCreate(query).matches = matches
+ },
+ trigger(query, matches) {
+ const mql = getOrCreate(query)
+ mql.matches = matches
+ const handlers = listeners.get(query) ?? []
+ const event = { matches, media: query } as MediaQueryListEvent
+ act(() => {
+ handlers.forEach((h) => h(event))
+ })
+ },
+ reset() {
+ state.clear()
+ listeners.clear()
+ },
+ }
+}
+
+const wrapper = ({ children }: { children: ReactNode }) => {children}
+
+describe('resolveTheme', () => {
+ it('returns the system preference when mode is "system"', () => {
+ expect(resolveTheme('system', { prefersDark: true })).toBe('dark')
+ expect(resolveTheme('system', { prefersDark: false })).toBe('light')
+ })
+
+ it('returns the explicit mode regardless of system preference', () => {
+ expect(resolveTheme('light', { prefersDark: true })).toBe('light')
+ expect(resolveTheme('dark', { prefersDark: false })).toBe('dark')
+ })
+})
+
+describe('useTheme / ThemeProvider', () => {
+ let media: MediaController
+
+ beforeEach(() => {
+ window.localStorage.clear()
+ document.documentElement.removeAttribute('data-theme')
+ document.documentElement.style.removeProperty('color-scheme')
+ media = installMatchMediaMock()
+ })
+
+ afterEach(() => {
+ media.reset()
+ // Restore the default no-match mock from setupTests.ts so subsequent
+ // test files in the same worker don't pick up our custom listener wiring.
+ ;(window.matchMedia as jest.Mock).mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }))
+ document.documentElement.removeAttribute('data-theme')
+ document.documentElement.style.removeProperty('color-scheme')
+ })
+
+ it('defaults to system mode resolving to light when no preference is stored', () => {
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.mode).toBe('system')
+ expect(result.current.resolved).toBe('light')
+ })
+
+ it('returns a no-op default outside the provider', () => {
+ const { result } = renderHook(() => useTheme())
+ expect(result.current.mode).toBe('system')
+ expect(result.current.resolved).toBe('light')
+ // setMode must not throw
+ expect(() => result.current.setMode('dark')).not.toThrow()
+ })
+
+ it('reads the persisted mode from localStorage on mount', () => {
+ window.localStorage.setItem(STORAGE_KEY, 'dark')
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.mode).toBe('dark')
+ expect(result.current.resolved).toBe('dark')
+ })
+
+ it('ignores an invalid persisted value and falls back to system', () => {
+ window.localStorage.setItem(STORAGE_KEY, 'rainbow')
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.mode).toBe('system')
+ })
+
+ it('persists the mode to localStorage when setMode is called', () => {
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ act(() => result.current.setMode('dark'))
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('dark')
+ expect(result.current.mode).toBe('dark')
+ })
+
+ it('resolves to dark when prefers-color-scheme is dark and mode is system', () => {
+ media.setMatches(PREFERS_DARK_QUERY, true)
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.resolved).toBe('dark')
+ })
+
+ it('updates resolved theme live when prefers-color-scheme changes', () => {
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.resolved).toBe('light')
+ media.trigger(PREFERS_DARK_QUERY, true)
+ expect(result.current.resolved).toBe('dark')
+ media.trigger(PREFERS_DARK_QUERY, false)
+ expect(result.current.resolved).toBe('light')
+ })
+
+ it('explicit light mode wins over system dark preference', () => {
+ media.setMatches(PREFERS_DARK_QUERY, true)
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.resolved).toBe('dark')
+ act(() => result.current.setMode('light'))
+ expect(result.current.resolved).toBe('light')
+ })
+
+ it('sets data-theme and color-scheme on documentElement to match resolved theme', () => {
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(document.documentElement.dataset.theme).toBe('light')
+ expect(document.documentElement.style.colorScheme).toBe('light')
+
+ act(() => result.current.setMode('dark'))
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ expect(document.documentElement.style.colorScheme).toBe('dark')
+ })
+
+ it('survives localStorage throwing on read', () => {
+ const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('access denied')
+ })
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(result.current.mode).toBe('system')
+ spy.mockRestore()
+ })
+
+ it('survives localStorage throwing on write', () => {
+ const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('quota exceeded')
+ })
+ const { result } = renderHook(() => useTheme(), { wrapper })
+ expect(() => act(() => result.current.setMode('dark'))).not.toThrow()
+ expect(result.current.mode).toBe('dark')
+ spy.mockRestore()
+ })
+
+ it('provides the same value to multiple consumers', async () => {
+ const user = userEvent.setup()
+ function Reader({ id }: { id: string }) {
+ const { resolved, mode, setMode } = useTheme()
+ return (
+
+ )
+ }
+ render(
+
+
+
+ ,
+ )
+ expect(screen.getByTestId('a')).toHaveTextContent('a:system:light')
+ expect(screen.getByTestId('b')).toHaveTextContent('b:system:light')
+
+ await user.click(screen.getByTestId('a'))
+ expect(screen.getByTestId('a')).toHaveTextContent('a:dark:dark')
+ expect(screen.getByTestId('b')).toHaveTextContent('b:dark:dark')
+ })
+
+ it('removes media-query listeners on unmount', () => {
+ const { unmount } = renderHook(() => useTheme(), { wrapper })
+ const prefersDark = window.matchMedia(PREFERS_DARK_QUERY) as unknown as MockMediaQueryList
+ unmount()
+ expect(prefersDark.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function))
+ })
+
+ it.each<[ThemeMode]>([['system'], ['light'], ['dark']])(
+ 'round-trips mode "%s" through localStorage',
+ (mode) => {
+ const { result, unmount } = renderHook(() => useTheme(), { wrapper })
+ act(() => result.current.setMode(mode))
+ unmount()
+ const { result: result2 } = renderHook(() => useTheme(), { wrapper })
+ expect(result2.current.mode).toBe(mode)
+ },
+ )
+})
diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx
new file mode 100644
index 0000000000..15bf85504f
--- /dev/null
+++ b/frontend/src/hooks/useTheme.tsx
@@ -0,0 +1,168 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import type { ReactNode } from 'react'
+import { FluentProvider, webDarkTheme, webLightTheme } from '@fluentui/react-components'
+import type { Theme } from '@fluentui/react-components'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/** The user's persisted preference. `'system'` defers to OS-level signals. */
+export type ThemeMode = 'system' | 'light' | 'dark'
+
+/** What is actually rendered after resolving system signals. */
+export type ResolvedTheme = 'light' | 'dark'
+
+export interface ThemeContextValue {
+ /** The user's persisted preference. */
+ mode: ThemeMode
+ /** The theme actually being rendered after resolving system signals. */
+ resolved: ResolvedTheme
+ /** Update the user preference. Persisted to localStorage. */
+ setMode: (mode: ThemeMode) => void
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const STORAGE_KEY = 'pyrit.themeMode'
+const PREFERS_DARK_QUERY = '(prefers-color-scheme: dark)'
+
+const VALID_MODES: readonly ThemeMode[] = ['system', 'light', 'dark']
+
+const FLUENT_THEMES: Record = {
+ light: webLightTheme,
+ dark: webDarkTheme,
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function isThemeMode(value: unknown): value is ThemeMode {
+ return typeof value === 'string' && (VALID_MODES as readonly string[]).includes(value)
+}
+
+function readStoredMode(): ThemeMode {
+ if (typeof window === 'undefined') return 'system'
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY)
+ return isThemeMode(raw) ? raw : 'system'
+ } catch {
+ return 'system'
+ }
+}
+
+function persistMode(mode: ThemeMode): void {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(STORAGE_KEY, mode)
+ } catch {
+ /* localStorage may be unavailable (private mode, quota, sandboxed iframe). */
+ }
+}
+
+function safeMatchMedia(query: string): MediaQueryList | null {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return null
+ }
+ try {
+ return window.matchMedia(query)
+ } catch {
+ return null
+ }
+}
+
+interface SystemSignals {
+ prefersDark: boolean
+}
+
+function readSystemSignals(): SystemSignals {
+ return {
+ prefersDark: safeMatchMedia(PREFERS_DARK_QUERY)?.matches ?? false,
+ }
+}
+
+/** Resolve the user preference + system signals to the theme we will render. */
+// eslint-disable-next-line react-refresh/only-export-components
+export function resolveTheme(mode: ThemeMode, signals: SystemSignals): ResolvedTheme {
+ if (mode === 'system') return signals.prefersDark ? 'dark' : 'light'
+ return mode
+}
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+const DEFAULT_CONTEXT: ThemeContextValue = {
+ mode: 'system',
+ resolved: 'light',
+ setMode: () => {},
+}
+
+const ThemeContext = createContext(DEFAULT_CONTEXT)
+
+/** Access the current theme state. Returns a no-op fallback outside the provider. */
+// eslint-disable-next-line react-refresh/only-export-components
+export function useTheme(): ThemeContextValue {
+ return useContext(ThemeContext)
+}
+
+// ---------------------------------------------------------------------------
+// Provider
+// ---------------------------------------------------------------------------
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ // Lazy initializer reads from localStorage exactly once, so the first paint
+ // is correct (no flash of wrong theme) and StrictMode double-render is safe.
+ const [mode, setModeState] = useState(() => readStoredMode())
+ const [signals, setSignals] = useState(() => readSystemSignals())
+
+ const setMode = useCallback((next: ThemeMode) => {
+ setModeState(next)
+ persistMode(next)
+ }, [])
+
+ // Subscribe to `prefers-color-scheme` so macOS dark-mode schedules and
+ // similar OS-level changes update without a reload.
+ useEffect(() => {
+ const prefersDark = safeMatchMedia(PREFERS_DARK_QUERY)
+ if (!prefersDark) return
+
+ const handleChange = () => setSignals(readSystemSignals())
+ handleChange()
+
+ prefersDark.addEventListener('change', handleChange)
+ return () => prefersDark.removeEventListener('change', handleChange)
+ }, [])
+
+ const resolved = useMemo(() => resolveTheme(mode, signals), [mode, signals])
+
+ // Apply theme-related attributes to so non-Fluent CSS (native
+ // scrollbars, form controls, anything in global.css) follows the theme.
+ useEffect(() => {
+ if (typeof document === 'undefined') return
+ const root = document.documentElement
+ root.dataset.theme = resolved
+ root.style.colorScheme = resolved
+ }, [resolved])
+
+ const value = useMemo(
+ () => ({ mode, resolved, setMode }),
+ [mode, resolved, setMode],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 9ff966daf5..1d01dc3038 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,14 +2,17 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { AuthProvider } from './auth/AuthProvider'
+import { ThemeProvider } from './hooks/useTheme'
import './styles/global.css'
document.title = 'Co-PyRIT'
ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
-
+
+
+
+
+
,
)