feat: add dark mode toggle with theme persistence
All checks were successful
Basil CI/CD Pipeline / Code Linting (push) Successful in 58s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m5s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m0s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m6s
Basil CI/CD Pipeline / API Tests (push) Successful in 2m44s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 3m9s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Build & Push Docker Images (push) Successful in 5m5s
Basil CI/CD Pipeline / Trigger Deployment (push) Successful in 11s

Implemented a simple dark mode toggle that persists user preference across sessions.

Changes:
- Add CSS custom properties for light and dark themes in App.css
- Create ThemeContext for global theme state management
- Create ThemeToggle component with moon/sun icons
- Update all color references to use CSS variables for theme support
- Add localStorage persistence for theme preference
- Include smooth transitions between themes

The toggle appears in the header next to the user menu and allows instant switching between light and dark modes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Paul R Kartchner
2026-01-16 15:23:24 -07:00
parent 67acf7e50e
commit da085b7332
5 changed files with 211 additions and 64 deletions

View File

@@ -1,3 +1,37 @@
:root {
/* Light mode colors */
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-tertiary: #f9f9f9;
--text-primary: #333333;
--text-secondary: #666666;
--text-tertiary: #999999;
--brand-primary: #2d5016;
--brand-secondary: #3d6821;
--brand-hover: #1f3710;
--border-color: #ddd;
--border-light: #eee;
--shadow: rgba(0,0,0,0.1);
--shadow-hover: rgba(0,0,0,0.15);
}
[data-theme="dark"] {
/* Dark mode colors */
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #242424;
--text-primary: #e0e0e0;
--text-secondary: #b0b0b0;
--text-tertiary: #808080;
--brand-primary: #4a7c2d;
--brand-secondary: #5a8c3d;
--brand-hover: #3d6821;
--border-color: #404040;
--border-light: #333333;
--shadow: rgba(0,0,0,0.3);
--shadow-hover: rgba(0,0,0,0.4);
}
* {
box-sizing: border-box;
margin: 0;
@@ -8,7 +42,9 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.app {
@@ -25,10 +61,10 @@ body {
}
.header {
background-color: #2d5016;
background-color: var(--brand-primary);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px var(--shadow);
}
.header .container {
@@ -73,7 +109,7 @@ nav a:hover {
}
.footer {
background-color: #2d5016;
background-color: var(--brand-primary);
color: white;
padding: 1rem 0;
text-align: center;
@@ -88,17 +124,17 @@ nav a:hover {
}
.recipe-card {
background: white;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
box-shadow: 0 2px 8px var(--shadow);
transition: transform 0.2s;
cursor: pointer;
}
.recipe-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 4px 12px var(--shadow-hover);
}
.recipe-card img {
@@ -113,19 +149,19 @@ nav a:hover {
.recipe-card h3 {
margin-bottom: 0.5rem;
color: #2d5016;
color: var(--brand-primary);
}
.recipe-card p {
color: #666;
color: var(--text-secondary);
font-size: 0.9rem;
}
.recipe-detail {
background: white;
background: var(--bg-secondary);
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
box-shadow: 0 2px 8px var(--shadow);
}
.recipe-actions {
@@ -151,7 +187,7 @@ nav a:hover {
}
.recipe-detail h2 {
color: #2d5016;
color: var(--brand-primary);
margin-bottom: 1rem;
}
@@ -159,7 +195,7 @@ nav a:hover {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
color: #666;
color: var(--text-secondary);
align-items: center;
flex-wrap: wrap;
}
@@ -186,7 +222,7 @@ nav a:hover {
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: #2d5016;
background-color: var(--brand-primary);
color: white;
border: none;
cursor: pointer;
@@ -194,7 +230,7 @@ nav a:hover {
}
.servings-adjuster button:hover:not(:disabled) {
background-color: #3d6821;
background-color: var(--brand-secondary);
}
.servings-adjuster button:disabled {
@@ -259,9 +295,9 @@ nav a:hover {
.recipe-section {
margin-bottom: 3rem;
padding: 1.5rem;
background-color: #f9f9f9;
background-color: var(--bg-tertiary);
border-radius: 8px;
border-left: 4px solid #2d5016;
border-left: 4px solid var(--brand-primary);
}
.section-header {
@@ -274,12 +310,12 @@ nav a:hover {
}
.section-header h3 {
color: #2d5016;
color: var(--brand-primary);
margin: 0;
}
.section-timing {
background-color: #2d5016;
background-color: var(--brand-primary);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 4px;
@@ -288,13 +324,13 @@ nav a:hover {
}
.instruction-timing {
color: #2d5016;
color: var(--brand-primary);
font-weight: 600;
font-size: 0.95rem;
}
.recipe-section h4 {
color: #2d5016;
color: var(--brand-primary);
margin-bottom: 1rem;
font-size: 1.1rem;
}
@@ -304,7 +340,7 @@ nav a:hover {
}
.ingredients h3, .instructions h3 {
color: #2d5016;
color: var(--brand-primary);
margin-bottom: 1rem;
}
@@ -315,7 +351,7 @@ nav a:hover {
.ingredients li {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--border-light);
}
.instructions ol {
@@ -335,16 +371,18 @@ nav a:hover {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
color: var(--text-primary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.form-group textarea {
@@ -353,7 +391,7 @@ nav a:hover {
}
button {
background-color: #2d5016;
background-color: var(--brand-primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
@@ -364,7 +402,7 @@ button {
}
button:hover {
background-color: #1f3710;
background-color: var(--brand-hover);
}
button:disabled {
@@ -383,15 +421,15 @@ button:disabled {
.loading {
text-align: center;
padding: 2rem;
color: #666;
color: var(--text-secondary);
}
/* Recipe Form Styles */
.recipe-form {
background: white;
background: var(--bg-secondary);
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
box-shadow: 0 2px 8px var(--shadow);
}
.form-row {
@@ -417,9 +455,9 @@ button:disabled {
}
.section-form {
background-color: #f9f9f9;
background-color: var(--bg-tertiary);
border-radius: 8px;
border-left: 4px solid #2d5016;
border-left: 4px solid var(--brand-primary);
padding: 1.5rem;
margin-bottom: 2rem;
}
@@ -433,17 +471,17 @@ button:disabled {
.section-form-header h4 {
margin: 0;
color: #2d5016;
color: var(--brand-primary);
}
.subsection {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
border-top: 1px solid var(--border-color);
}
.subsection h5 {
color: #2d5016;
color: var(--brand-primary);
margin-bottom: 1rem;
}
@@ -467,7 +505,7 @@ button:disabled {
gap: 0.75rem;
margin-bottom: 1rem;
align-items: flex-start;
background-color: white;
background-color: var(--bg-secondary);
padding: 0.75rem;
border-radius: 6px;
border: 2px solid transparent;
@@ -475,20 +513,20 @@ button:disabled {
}
.instruction-row:hover {
border-color: #e0e0e0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
border-color: var(--border-color);
box-shadow: 0 2px 4px var(--shadow);
}
.instruction-row.dragging {
opacity: 0.5;
background-color: #f5f5f5;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
border-color: #2d5016;
background-color: var(--bg-primary);
box-shadow: 0 4px 8px var(--shadow-hover);
border-color: var(--brand-primary);
}
.instruction-drag-handle {
cursor: grab;
color: #999;
color: var(--text-tertiary);
font-size: 1.2rem;
display: flex;
align-items: center;
@@ -498,7 +536,7 @@ button:disabled {
}
.instruction-drag-handle:hover {
color: #2d5016;
color: var(--brand-primary);
}
.instruction-drag-handle:active {
@@ -508,7 +546,7 @@ button:disabled {
.instruction-number {
min-width: 30px;
height: 30px;
background-color: #2d5016;
background-color: var(--brand-primary);
color: white;
border-radius: 50%;
display: flex;
@@ -527,9 +565,11 @@ button:disabled {
.instruction-timing-input {
padding: 0.5rem;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.btn-secondary {
@@ -586,7 +626,7 @@ button:disabled {
gap: 1rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid #eee;
border-top: 2px solid var(--border-light);
}
.form-section {
@@ -596,9 +636,9 @@ button:disabled {
/* Image Upload Styles */
.image-upload-section {
padding: 1.5rem;
background-color: #f9f9f9;
background-color: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
}
.current-image {
@@ -705,7 +745,7 @@ button:disabled {
align-items: center;
padding: 1rem;
margin-bottom: 0.75rem;
background-color: #f5f5f5;
background-color: var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;

View File

@@ -1,7 +1,9 @@
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import ProtectedRoute from './components/ProtectedRoute';
import UserMenu from './components/UserMenu';
import ThemeToggle from './components/ThemeToggle';
import Login from './pages/Login';
import Register from './pages/Register';
import AuthCallback from './pages/AuthCallback';
@@ -19,20 +21,24 @@ import './App.css';
function App() {
return (
<Router>
<AuthProvider>
<div className="app">
<header className="header">
<div className="container">
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
<nav>
<Link to="/">Cookbooks</Link>
<Link to="/recipes">All Recipes</Link>
<Link to="/recipes/new">New Recipe</Link>
<Link to="/recipes/import">Import Recipe</Link>
</nav>
<UserMenu />
</div>
</header>
<ThemeProvider>
<AuthProvider>
<div className="app">
<header className="header">
<div className="container">
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
<nav>
<Link to="/">Cookbooks</Link>
<Link to="/recipes">All Recipes</Link>
<Link to="/recipes/new">New Recipe</Link>
<Link to="/recipes/import">Import Recipe</Link>
</nav>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<ThemeToggle />
<UserMenu />
</div>
</div>
</header>
<main className="main">
<div className="container">
@@ -62,7 +68,8 @@ function App() {
</div>
</footer>
</div>
</AuthProvider>
</AuthProvider>
</ThemeProvider>
</Router>
);
}

View File

@@ -0,0 +1,24 @@
import { useTheme } from '../contexts/ThemeContext';
import '../styles/ThemeToggle.css';
function ThemeToggle() {
try {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="theme-toggle"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
} catch (error) {
console.error('ThemeToggle error:', error);
return null;
}
}
export default ThemeToggle;

View File

@@ -0,0 +1,53 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_STORAGE_KEY = 'basil_theme';
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
// Load theme from localStorage safely
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return (saved === 'dark' ? 'dark' : 'light') as Theme;
} catch (e) {
return 'light';
}
});
useEffect(() => {
// Apply theme to document
document.documentElement.setAttribute('data-theme', theme);
// Save to localStorage safely
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (e) {
console.warn('Failed to save theme preference:', e);
}
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,23 @@
.theme-toggle {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.2rem;
padding: 0;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.theme-toggle:active {
transform: scale(0.95);
}