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

View File

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