From 5f53c82279d8b95d1099f27f1f5fc452f71a4c74 Mon Sep 17 00:00:00 2001 From: "Claude frontend-developer (Opus 4.6)" Date: Wed, 18 Feb 2026 22:53:55 +0000 Subject: [PATCH] feat(theme): add dark mode with user preference toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ThemeContext with Light/Dark/System preference, ThemeToggle component in sidebar, and dark mode token overrides. Preference persisted to localStorage, system preference respected via window.matchMedia and reactive OS-preference change listener. - tokens.css: complete [data-theme="dark"] overrides for all semantic tokens (backgrounds, text, borders, primary, danger, success, sidebar, focus rings, overlays, badges, shadows) - ThemeContext.tsx: ThemeProvider + useTheme hook; reads/writes localStorage; resolves 'system' via matchMedia; sets document.documentElement.dataset.theme on every change - ThemeToggle component: cycles Light → Dark → System with inline SVG sun/moon/monitor icons (no icon library dependency) - Sidebar: ThemeToggle placed between nav separators (before logout) - App.tsx: ThemeProvider wraps AuthProvider inside BrowserRouter - test/setupTests.ts: polyfill window.matchMedia for jsdom - AppShell overlay: add data-testid="sidebar-overlay" so tests can distinguish it from SVG aria-hidden="true" icons - Updated Sidebar and AppShell tests to mock ThemeContext and use the new data-testid selector respectively Fixes #119 Co-Authored-By: Claude frontend-developer (Sonnet 4.6) --- client/src/App.tsx | 77 ++++++----- .../src/components/AppShell/AppShell.test.tsx | 31 +++-- client/src/components/AppShell/AppShell.tsx | 7 +- .../src/components/Sidebar/Sidebar.module.css | 5 + .../src/components/Sidebar/Sidebar.test.tsx | 19 ++- client/src/components/Sidebar/Sidebar.tsx | 5 + .../ThemeToggle/ThemeToggle.module.css | 43 ++++++ .../components/ThemeToggle/ThemeToggle.tsx | 113 +++++++++++++++ client/src/contexts/ThemeContext.tsx | 93 +++++++++++++ client/src/styles/tokens.css | 130 +++++++++++++++--- client/src/test/setupTests.ts | 19 +++ 11 files changed, 474 insertions(+), 68 deletions(-) create mode 100644 client/src/components/ThemeToggle/ThemeToggle.module.css create mode 100644 client/src/components/ThemeToggle/ThemeToggle.tsx create mode 100644 client/src/contexts/ThemeContext.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 4a03b903b..8c8c8ad53 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AppShell } from './components/AppShell/AppShell'; import { AuthProvider } from './contexts/AuthContext'; +import { ThemeProvider } from './contexts/ThemeContext'; import { AuthGuard } from './components/AuthGuard/AuthGuard'; const SetupPage = lazy(() => import('./pages/SetupPage/SetupPage')); @@ -22,45 +23,47 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage')); export function App() { return ( - - - {/* Auth routes (no AppShell wrapper) */} - Loading...}> - - - } - /> - Loading...}> - - - } - /> + + + + {/* Auth routes (no AppShell wrapper) */} + Loading...}> + + + } + /> + Loading...}> + + + } + /> - {/* Protected app routes (with AuthGuard and AppShell wrapper) */} - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Protected app routes (with AuthGuard and AppShell wrapper) */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - - - + + + ); } diff --git a/client/src/components/AppShell/AppShell.test.tsx b/client/src/components/AppShell/AppShell.test.tsx index 040325464..8696e1dd9 100644 --- a/client/src/components/AppShell/AppShell.test.tsx +++ b/client/src/components/AppShell/AppShell.test.tsx @@ -2,6 +2,7 @@ * @jest-environment jsdom */ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type React from 'react'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { lazy } from 'react'; @@ -30,6 +31,16 @@ jest.unstable_mockModule('../../contexts/AuthContext.js', () => ({ }), })); +// Mock ThemeContext so ThemeToggle (inside Sidebar) can call useTheme() +jest.unstable_mockModule('../../contexts/ThemeContext.js', () => ({ + useTheme: () => ({ + theme: 'system', + resolvedTheme: 'light', + setTheme: jest.fn(), + }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})); + describe('AppShell', () => { let AppShellModule: typeof AppShellTypes; @@ -147,7 +158,7 @@ describe('AppShell', () => { ); // Overlay should not exist in DOM when sidebar is closed - const overlay = document.querySelector('[aria-hidden="true"]'); + const overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).not.toBeInTheDocument(); }); @@ -165,7 +176,7 @@ describe('AppShell', () => { await user.click(menuButton); // Overlay should now exist in DOM - const overlay = document.querySelector('[aria-hidden="true"]'); + const overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).toBeInTheDocument(); }); @@ -184,14 +195,14 @@ describe('AppShell', () => { await user.click(menuButton); // Verify overlay exists - let overlay = document.querySelector('[aria-hidden="true"]'); + let overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).toBeInTheDocument(); // Click overlay to close await user.click(overlay as HTMLElement); // Overlay should be removed - overlay = document.querySelector('[aria-hidden="true"]'); + overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).not.toBeInTheDocument(); }); @@ -210,14 +221,14 @@ describe('AppShell', () => { await user.click(menuButton); // Verify overlay exists - let overlay = document.querySelector('[aria-hidden="true"]'); + let overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).toBeInTheDocument(); // Press Escape await user.keyboard('{Escape}'); // Overlay should be removed - overlay = document.querySelector('[aria-hidden="true"]'); + overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).not.toBeInTheDocument(); }); @@ -258,7 +269,7 @@ describe('AppShell', () => { // Open await user.click(menuButton); - let overlay = document.querySelector('[aria-hidden="true"]'); + let overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).toBeInTheDocument(); // Header button should now say "Close menu" @@ -267,7 +278,7 @@ describe('AppShell', () => { // Close await user.click(menuButton); - overlay = document.querySelector('[aria-hidden="true"]'); + overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).not.toBeInTheDocument(); // Button should now say "Open menu" again @@ -275,7 +286,7 @@ describe('AppShell', () => { // Open again await user.click(menuButton); - overlay = document.querySelector('[aria-hidden="true"]'); + overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).toBeInTheDocument(); }); @@ -328,7 +339,7 @@ describe('AppShell', () => { expect(sidebar.className).not.toMatch(/open/); // Overlay should be removed - const overlay = document.querySelector('[aria-hidden="true"]'); + const overlay = document.querySelector('[data-testid="sidebar-overlay"]'); expect(overlay).not.toBeInTheDocument(); }); diff --git a/client/src/components/AppShell/AppShell.tsx b/client/src/components/AppShell/AppShell.tsx index 70e2f831d..c2c681d54 100644 --- a/client/src/components/AppShell/AppShell.tsx +++ b/client/src/components/AppShell/AppShell.tsx @@ -32,7 +32,12 @@ export function AppShell() {
{isSidebarOpen && ( -