From 2a031f6d2ecacc5debdf113762e0bccdf95e6b90 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 10 Jun 2026 17:01:22 -0400 Subject: [PATCH 1/2] FE Add ThemeProvider with System/Light/Dark picker and auto high-contrast Replace prop-drilled isDarkMode state with a ThemeProvider/useTheme context hook in src/hooks/ (matching the useConnectionHealth pattern and the style guide's cross-cutting concern rule). Behavior changes: - Three-mode picker (System/Light/Dark) in the sidebar via a Fluent Menu/MenuItemRadio. - 'System' follows prefers-color-scheme live. - Selection persists to localStorage. - forced-colors media query auto-applies Fluent's high-contrast theme regardless of mode, so OS HC users get a usable UI without a manual toggle. - documentElement gets data-theme and color-scheme attributes for any CSS overrides. Refactors: - App.tsx, MainLayout.tsx, and Navigation.tsx no longer pass theme state around. - main.tsx wraps App in outside AuthProvider so theme applies during auth flows. Tests: - New useTheme.test.tsx covers resolver, persistence, live media query updates, document attribute writes, and localStorage failure modes. - Navigation.test.tsx rewritten around the new menu picker. - App.test.tsx and MainLayout.test.tsx updated to drop dropped theme props/tests and wrap with ThemeProvider where needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/App.test.tsx | 85 ++---- frontend/src/App.tsx | 12 +- .../src/components/Layout/MainLayout.test.tsx | 26 +- frontend/src/components/Layout/MainLayout.tsx | 6 - .../components/Sidebar/Navigation.test.tsx | 126 ++++---- .../src/components/Sidebar/Navigation.tsx | 69 ++++- frontend/src/hooks/useTheme.test.tsx | 285 ++++++++++++++++++ frontend/src/hooks/useTheme.tsx | 200 ++++++++++++ frontend/src/main.tsx | 9 +- 9 files changed, 654 insertions(+), 164 deletions(-) create mode 100644 frontend/src/hooks/useTheme.test.tsx create mode 100644 frontend/src/hooks/useTheme.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d3896fa297..746f18e0a0 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -5,6 +5,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import App from "./App"; +import { ThemeProvider } from "./hooks/useTheme"; import { attacksApi } from "./services/api"; const mockGetActiveAccount = jest.fn(); @@ -45,22 +46,15 @@ jest.mock("./components/Labels/LabelsBar", () => { jest.mock("./components/Layout/MainLayout", () => { const MockMainLayout = ({ children, - onToggleTheme, - isDarkMode, currentView, onNavigate, }: { children: React.ReactNode; - onToggleTheme: () => void; - isDarkMode: boolean; currentView: string; onNavigate: (view: string) => void; }) => { return ( -
- +
@@ -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 d4aeac6a7d..a5cacfaba2 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' @@ -39,7 +38,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 }) @@ -174,20 +172,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
-
) } diff --git a/frontend/src/hooks/useTheme.test.tsx b/frontend/src/hooks/useTheme.test.tsx new file mode 100644 index 0000000000..af3d3f2017 --- /dev/null +++ b/frontend/src/hooks/useTheme.test.tsx @@ -0,0 +1,285 @@ +/** + * 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 FORCED_COLORS_QUERY = '(forced-colors: active)' +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 high-contrast whenever forced-colors is active, regardless of mode', () => { + expect(resolveTheme('light', { forcedColors: true, prefersDark: false })).toBe('high-contrast') + expect(resolveTheme('dark', { forcedColors: true, prefersDark: true })).toBe('high-contrast') + expect(resolveTheme('system', { forcedColors: true, prefersDark: false })).toBe('high-contrast') + }) + + it('returns the system preference when mode is "system"', () => { + expect(resolveTheme('system', { forcedColors: false, prefersDark: true })).toBe('dark') + expect(resolveTheme('system', { forcedColors: false, prefersDark: false })).toBe('light') + }) + + it('returns the explicit mode when not "system" and forced-colors is off', () => { + expect(resolveTheme('light', { forcedColors: false, prefersDark: true })).toBe('light') + expect(resolveTheme('dark', { forcedColors: false, 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('resolves to high-contrast when forced-colors is active, overriding mode', () => { + media.setMatches(FORCED_COLORS_QUERY, true) + window.localStorage.setItem(STORAGE_KEY, 'light') + const { result } = renderHook(() => useTheme(), { wrapper }) + expect(result.current.mode).toBe('light') + expect(result.current.resolved).toBe('high-contrast') + }) + + 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('updates resolved theme live when forced-colors becomes active', () => { + const { result } = renderHook(() => useTheme(), { wrapper }) + expect(result.current.resolved).toBe('light') + media.trigger(FORCED_COLORS_QUERY, true) + expect(result.current.resolved).toBe('high-contrast') + media.trigger(FORCED_COLORS_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') + + media.trigger(FORCED_COLORS_QUERY, true) + expect(document.documentElement.dataset.theme).toBe('high-contrast') + expect(document.documentElement.style.colorScheme).toBe('light 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 forced = window.matchMedia(FORCED_COLORS_QUERY) as unknown as MockMediaQueryList + const prefersDark = window.matchMedia(PREFERS_DARK_QUERY) as unknown as MockMediaQueryList + unmount() + expect(forced.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)) + 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..ec36ee6256 --- /dev/null +++ b/frontend/src/hooks/useTheme.tsx @@ -0,0 +1,200 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import type { ReactNode } from 'react' +import { + FluentProvider, + createHighContrastTheme, + 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. Includes `'high-contrast'` for forced-colors. */ +export type ResolvedTheme = 'light' | 'dark' | 'high-contrast' + +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 FORCED_COLORS_QUERY = '(forced-colors: active)' +const PREFERS_DARK_QUERY = '(prefers-color-scheme: dark)' + +const VALID_MODES: readonly ThemeMode[] = ['system', 'light', 'dark'] + +// Build the high-contrast theme once at module load — `createHighContrastTheme` +// returns a 459-key object and is purely derived from defaults. +const HIGH_CONTRAST_THEME = createHighContrastTheme() + +const FLUENT_THEMES: Record = { + light: webLightTheme, + dark: webDarkTheme, + 'high-contrast': HIGH_CONTRAST_THEME, +} + +// --------------------------------------------------------------------------- +// 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 { + forcedColors: boolean + prefersDark: boolean +} + +function readSystemSignals(): SystemSignals { + return { + forcedColors: safeMatchMedia(FORCED_COLORS_QUERY)?.matches ?? false, + prefersDark: safeMatchMedia(PREFERS_DARK_QUERY)?.matches ?? false, + } +} + +/** + * Resolve the user preference + system signals to the theme we will actually + * render. Forced-colors always wins: if Windows / browser High Contrast is on, + * picking any non-HC theme produces a broken-looking UI because the browser + * repaints with system colors anyway. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function resolveTheme(mode: ThemeMode, signals: SystemSignals): ResolvedTheme { + if (signals.forcedColors) return 'high-contrast' + 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 OS-level signals. Both `forced-colors` and + // `prefers-color-scheme` can change at runtime (Windows HC toggle, macOS + // dark-mode schedule, etc.). We listen to both and re-read on any change. + useEffect(() => { + const forced = safeMatchMedia(FORCED_COLORS_QUERY) + const prefersDark = safeMatchMedia(PREFERS_DARK_QUERY) + + if (!forced && !prefersDark) return + + const handleChange = () => setSignals(readSystemSignals()) + + // Sync once on mount in case signals changed between initial render and + // effect (e.g., a slow first paint). + handleChange() + + forced?.addEventListener('change', handleChange) + prefersDark?.addEventListener('change', handleChange) + + return () => { + forced?.removeEventListener('change', handleChange) + 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 + // `light dark` for HC lets the browser pick whichever matches the active + // forced-colors palette; native widgets are already overridden by the OS. + root.style.colorScheme = resolved === 'high-contrast' ? 'light dark' : 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( - - - + + + + + , ) From f77b480be4f78dc5c827ea6cfac268a1f30797a9 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 10 Jun 2026 17:19:55 -0400 Subject: [PATCH 2/2] FE Drop high-contrast auto-detection from ThemeProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't actually QA the app in forced-colors mode and Fluent's HC theme only covers Fluent components — claiming HC support set an expectation we hadn't validated. HC users still get the browser's default forced-colors fallback; if we want real HC support later it should be a dedicated effort. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/hooks/useTheme.test.tsx | 40 +++------------------ frontend/src/hooks/useTheme.tsx | 52 ++++++---------------------- 2 files changed, 15 insertions(+), 77 deletions(-) diff --git a/frontend/src/hooks/useTheme.test.tsx b/frontend/src/hooks/useTheme.test.tsx index af3d3f2017..d54d8af41b 100644 --- a/frontend/src/hooks/useTheme.test.tsx +++ b/frontend/src/hooks/useTheme.test.tsx @@ -10,7 +10,6 @@ import { ThemeProvider, resolveTheme, useTheme } from './useTheme' import type { ThemeMode } from './useTheme' const STORAGE_KEY = 'pyrit.themeMode' -const FORCED_COLORS_QUERY = '(forced-colors: active)' const PREFERS_DARK_QUERY = '(prefers-color-scheme: dark)' type MediaListener = (event: MediaQueryListEvent) => void @@ -87,20 +86,14 @@ function installMatchMediaMock(): MediaController { const wrapper = ({ children }: { children: ReactNode }) => {children} describe('resolveTheme', () => { - it('returns high-contrast whenever forced-colors is active, regardless of mode', () => { - expect(resolveTheme('light', { forcedColors: true, prefersDark: false })).toBe('high-contrast') - expect(resolveTheme('dark', { forcedColors: true, prefersDark: true })).toBe('high-contrast') - expect(resolveTheme('system', { forcedColors: true, prefersDark: false })).toBe('high-contrast') - }) - it('returns the system preference when mode is "system"', () => { - expect(resolveTheme('system', { forcedColors: false, prefersDark: true })).toBe('dark') - expect(resolveTheme('system', { forcedColors: false, prefersDark: false })).toBe('light') + expect(resolveTheme('system', { prefersDark: true })).toBe('dark') + expect(resolveTheme('system', { prefersDark: false })).toBe('light') }) - it('returns the explicit mode when not "system" and forced-colors is off', () => { - expect(resolveTheme('light', { forcedColors: false, prefersDark: true })).toBe('light') - expect(resolveTheme('dark', { forcedColors: false, prefersDark: false })).toBe('dark') + it('returns the explicit mode regardless of system preference', () => { + expect(resolveTheme('light', { prefersDark: true })).toBe('light') + expect(resolveTheme('dark', { prefersDark: false })).toBe('dark') }) }) @@ -172,14 +165,6 @@ describe('useTheme / ThemeProvider', () => { expect(result.current.resolved).toBe('dark') }) - it('resolves to high-contrast when forced-colors is active, overriding mode', () => { - media.setMatches(FORCED_COLORS_QUERY, true) - window.localStorage.setItem(STORAGE_KEY, 'light') - const { result } = renderHook(() => useTheme(), { wrapper }) - expect(result.current.mode).toBe('light') - expect(result.current.resolved).toBe('high-contrast') - }) - it('updates resolved theme live when prefers-color-scheme changes', () => { const { result } = renderHook(() => useTheme(), { wrapper }) expect(result.current.resolved).toBe('light') @@ -189,15 +174,6 @@ describe('useTheme / ThemeProvider', () => { expect(result.current.resolved).toBe('light') }) - it('updates resolved theme live when forced-colors becomes active', () => { - const { result } = renderHook(() => useTheme(), { wrapper }) - expect(result.current.resolved).toBe('light') - media.trigger(FORCED_COLORS_QUERY, true) - expect(result.current.resolved).toBe('high-contrast') - media.trigger(FORCED_COLORS_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 }) @@ -214,10 +190,6 @@ describe('useTheme / ThemeProvider', () => { act(() => result.current.setMode('dark')) expect(document.documentElement.dataset.theme).toBe('dark') expect(document.documentElement.style.colorScheme).toBe('dark') - - media.trigger(FORCED_COLORS_QUERY, true) - expect(document.documentElement.dataset.theme).toBe('high-contrast') - expect(document.documentElement.style.colorScheme).toBe('light dark') }) it('survives localStorage throwing on read', () => { @@ -265,10 +237,8 @@ describe('useTheme / ThemeProvider', () => { it('removes media-query listeners on unmount', () => { const { unmount } = renderHook(() => useTheme(), { wrapper }) - const forced = window.matchMedia(FORCED_COLORS_QUERY) as unknown as MockMediaQueryList const prefersDark = window.matchMedia(PREFERS_DARK_QUERY) as unknown as MockMediaQueryList unmount() - expect(forced.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)) expect(prefersDark.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)) }) diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index ec36ee6256..15bf85504f 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -7,12 +7,7 @@ import { useState, } from 'react' import type { ReactNode } from 'react' -import { - FluentProvider, - createHighContrastTheme, - webDarkTheme, - webLightTheme, -} from '@fluentui/react-components' +import { FluentProvider, webDarkTheme, webLightTheme } from '@fluentui/react-components' import type { Theme } from '@fluentui/react-components' // --------------------------------------------------------------------------- @@ -22,8 +17,8 @@ import type { Theme } from '@fluentui/react-components' /** The user's persisted preference. `'system'` defers to OS-level signals. */ export type ThemeMode = 'system' | 'light' | 'dark' -/** What is actually rendered. Includes `'high-contrast'` for forced-colors. */ -export type ResolvedTheme = 'light' | 'dark' | 'high-contrast' +/** What is actually rendered after resolving system signals. */ +export type ResolvedTheme = 'light' | 'dark' export interface ThemeContextValue { /** The user's persisted preference. */ @@ -39,19 +34,13 @@ export interface ThemeContextValue { // --------------------------------------------------------------------------- const STORAGE_KEY = 'pyrit.themeMode' -const FORCED_COLORS_QUERY = '(forced-colors: active)' const PREFERS_DARK_QUERY = '(prefers-color-scheme: dark)' const VALID_MODES: readonly ThemeMode[] = ['system', 'light', 'dark'] -// Build the high-contrast theme once at module load — `createHighContrastTheme` -// returns a 459-key object and is purely derived from defaults. -const HIGH_CONTRAST_THEME = createHighContrastTheme() - const FLUENT_THEMES: Record = { light: webLightTheme, dark: webDarkTheme, - 'high-contrast': HIGH_CONTRAST_THEME, } // --------------------------------------------------------------------------- @@ -93,26 +82,18 @@ function safeMatchMedia(query: string): MediaQueryList | null { } interface SystemSignals { - forcedColors: boolean prefersDark: boolean } function readSystemSignals(): SystemSignals { return { - forcedColors: safeMatchMedia(FORCED_COLORS_QUERY)?.matches ?? false, prefersDark: safeMatchMedia(PREFERS_DARK_QUERY)?.matches ?? false, } } -/** - * Resolve the user preference + system signals to the theme we will actually - * render. Forced-colors always wins: if Windows / browser High Contrast is on, - * picking any non-HC theme produces a broken-looking UI because the browser - * repaints with system colors anyway. - */ +/** 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 (signals.forcedColors) return 'high-contrast' if (mode === 'system') return signals.prefersDark ? 'dark' : 'light' return mode } @@ -150,28 +131,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) { persistMode(next) }, []) - // Subscribe to OS-level signals. Both `forced-colors` and - // `prefers-color-scheme` can change at runtime (Windows HC toggle, macOS - // dark-mode schedule, etc.). We listen to both and re-read on any change. + // Subscribe to `prefers-color-scheme` so macOS dark-mode schedules and + // similar OS-level changes update without a reload. useEffect(() => { - const forced = safeMatchMedia(FORCED_COLORS_QUERY) const prefersDark = safeMatchMedia(PREFERS_DARK_QUERY) - - if (!forced && !prefersDark) return + if (!prefersDark) return const handleChange = () => setSignals(readSystemSignals()) - - // Sync once on mount in case signals changed between initial render and - // effect (e.g., a slow first paint). handleChange() - forced?.addEventListener('change', handleChange) - prefersDark?.addEventListener('change', handleChange) - - return () => { - forced?.removeEventListener('change', handleChange) - prefersDark?.removeEventListener('change', handleChange) - } + prefersDark.addEventListener('change', handleChange) + return () => prefersDark.removeEventListener('change', handleChange) }, []) const resolved = useMemo(() => resolveTheme(mode, signals), [mode, signals]) @@ -182,9 +152,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { if (typeof document === 'undefined') return const root = document.documentElement root.dataset.theme = resolved - // `light dark` for HC lets the browser pick whichever matches the active - // forced-colors palette; native widgets are already overridden by the OS. - root.style.colorScheme = resolved === 'high-contrast' ? 'light dark' : resolved + root.style.colorScheme = resolved }, [resolved]) const value = useMemo(