feat: Implement dual theme system with purple/pink and high contrast modes
Added comprehensive theme switching system with two color palettes: **New Default Theme: Purple/Pink Gradient** - Primary: #8b52ff (vibrant purple) → #d194e6 (light purple) - Secondary: #ff7094 (pink) → #ffb3e4 (light pink) - Modern, energetic gradient aesthetic - Created purpleTheme.ts with full MUI theme configuration **High Contrast Theme: Warm Peach (Original)** - Renamed maternalTheme to highContrastTheme - Larger text sizes (7.5% increase for accessibility) - Stronger shadows and bolder fonts - Optimized for readability and accessibility **Theme Infrastructure:** 1. **ThemeContext** (contexts/ThemeContext.tsx) - Created theme context provider with useThemeContext hook - Theme modes: 'standard' (purple) | 'highContrast' (peach) - localStorage persistence with key: maternal_theme_preference - Prevents flash of wrong theme on load 2. **ThemeRegistry Updates** (components/ThemeRegistry.tsx) - Wrapped with ThemeContextProvider - Dynamic theme switching via useThemeContext - Maintains backward compatibility 3. **Settings Page Toggle** (app/settings/page.tsx) - Added theme switcher in Preferences section - Switch control with descriptive labels - Explains High Contrast benefits - Instant theme switching without reload 4. **CSS Variables** (app/globals.css) - Added --color-1 through --color-5 for purple gradient - Added --warm-1 through --warm-5 for peach palette - Available for custom styling **Features:** ✅ Instant theme switching (no page reload) ✅ Persistent preference across sessions ✅ Accessibility-focused high contrast mode ✅ Modern purple/pink gradient as default ✅ Smooth transitions between themes ✅ All existing components work with both themes **Files Created:** - contexts/ThemeContext.tsx - styles/themes/purpleTheme.ts - styles/themes/highContrastTheme.ts **Files Modified:** - components/ThemeRegistry.tsx - app/settings/page.tsx - app/globals.css Resolves: High Priority Feature #12 - Secondary Color Palette & Accessibility Toggle 🎉 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,20 @@
|
|||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 0, 0, 0;
|
||||||
--background-start-rgb: 214, 219, 220;
|
--background-start-rgb: 214, 219, 220;
|
||||||
--background-end-rgb: 255, 255, 255;
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
|
||||||
|
/* Purple/Pink Gradient Theme (Default) */
|
||||||
|
--color-1: #8b52ff;
|
||||||
|
--color-2: #d194e6;
|
||||||
|
--color-3: #f2a6f2;
|
||||||
|
--color-4: #ffb3e4;
|
||||||
|
--color-5: #ff7094;
|
||||||
|
|
||||||
|
/* Warm Peach Theme (High Contrast) */
|
||||||
|
--warm-1: #FFB6C1;
|
||||||
|
--warm-2: #FFDAB9;
|
||||||
|
--warm-3: #FFE4E1;
|
||||||
|
--warm-4: #FFD4CC;
|
||||||
|
--warm-5: #FF8B7D;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import { TimeFormatSelector } from '@/components/settings/TimeFormatSelector';
|
|||||||
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useThemeContext } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const { user, logout, refreshUser } = useAuth();
|
const { user, logout, refreshUser } = useAuth();
|
||||||
|
const { themeMode, setThemeMode } = useThemeContext();
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [photoUrl, setPhotoUrl] = useState(user?.photoUrl || '');
|
const [photoUrl, setPhotoUrl] = useState(user?.photoUrl || '');
|
||||||
const [timezone, setTimezone] = useState(user?.timezone || 'UTC');
|
const [timezone, setTimezone] = useState(user?.timezone || 'UTC');
|
||||||
@@ -194,6 +196,25 @@ export default function SettingsPage() {
|
|||||||
<TimeZoneSelector value={timezone} onChange={setTimezone} />
|
<TimeZoneSelector value={timezone} onChange={setTimezone} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<TimeFormatSelector value={timeFormat} onChange={setTimeFormat} />
|
<TimeFormatSelector value={timeFormat} onChange={setTimeFormat} />
|
||||||
|
<Divider />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" fontWeight="600" gutterBottom>
|
||||||
|
Theme Mode
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
High Contrast mode uses larger text and stronger colors for better readability
|
||||||
|
</Typography>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={themeMode === 'highContrast'}
|
||||||
|
onChange={(e) => setThemeMode(e.target.checked ? 'highContrast' : 'standard')}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={themeMode === 'highContrast' ? 'High Contrast Mode' : 'Standard Mode'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,19 +3,31 @@
|
|||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
|
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
|
||||||
import { maternalTheme } from '@/styles/themes/maternalTheme';
|
|
||||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||||
|
import { ThemeContextProvider, useThemeContext } from '@/contexts/ThemeContext';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
function ThemeProviderWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const { currentTheme } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={currentTheme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeRegistry({ children }: { children: ReactNode }) {
|
export function ThemeRegistry({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AppRouterCacheProvider>
|
<AppRouterCacheProvider>
|
||||||
<ThemeProvider theme={maternalTheme}>
|
<ThemeContextProvider>
|
||||||
<CssBaseline />
|
<ThemeProviderWrapper>
|
||||||
<AuthProvider>
|
|
||||||
{children}
|
{children}
|
||||||
</AuthProvider>
|
</ThemeProviderWrapper>
|
||||||
</ThemeProvider>
|
</ThemeContextProvider>
|
||||||
</AppRouterCacheProvider>
|
</AppRouterCacheProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
maternal-web/contexts/ThemeContext.tsx
Normal file
65
maternal-web/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { Theme } from '@mui/material/styles';
|
||||||
|
import { purpleTheme } from '@/styles/themes/purpleTheme';
|
||||||
|
import { highContrastTheme } from '@/styles/themes/highContrastTheme';
|
||||||
|
|
||||||
|
export type ThemeMode = 'standard' | 'highContrast';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setThemeMode: (mode: ThemeMode) => void;
|
||||||
|
currentTheme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'maternal_theme_preference';
|
||||||
|
|
||||||
|
export function ThemeContextProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [themeMode, setThemeModeState] = useState<ThemeMode>('standard');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Load theme preference from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null;
|
||||||
|
if (savedTheme === 'highContrast' || savedTheme === 'standard') {
|
||||||
|
setThemeModeState(savedTheme);
|
||||||
|
}
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save theme preference to localStorage when it changes
|
||||||
|
const setThemeMode = (mode: ThemeMode) => {
|
||||||
|
setThemeModeState(mode);
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newMode = themeMode === 'standard' ? 'highContrast' : 'standard';
|
||||||
|
setThemeMode(newMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTheme = themeMode === 'highContrast' ? highContrastTheme : purpleTheme;
|
||||||
|
|
||||||
|
// Prevent flash of wrong theme
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ themeMode, toggleTheme, setThemeMode, currentTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeContext() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useThemeContext must be used within a ThemeContextProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
127
maternal-web/styles/themes/highContrastTheme.ts
Normal file
127
maternal-web/styles/themes/highContrastTheme.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// High Contrast Warm Peach theme - Original theme, now accessibility option
|
||||||
|
export const highContrastTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: '#FFB6C1', // Light pink/rose
|
||||||
|
light: '#FFE4E1', // Misty rose
|
||||||
|
dark: '#DB7093', // Pale violet red
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#FFDAB9', // Peach puff
|
||||||
|
light: '#FFE5CC',
|
||||||
|
dark: '#FFB347', // Deep peach
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#FFF9F5', // Warm white
|
||||||
|
paper: '#FFFFFF',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#2D3748', // Dark gray - 4.5:1+ contrast
|
||||||
|
secondary: '#4A5568', // Darker gray for better contrast (7:1+ on white)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h1: {
|
||||||
|
fontSize: '2.15rem', // 7.5% larger for accessibility
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '1.88rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '1.61rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
fontSize: '1.34rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontSize: '1.21rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontSize: '1.07rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
body1: {
|
||||||
|
fontSize: '1.07rem', // Slightly larger for readability
|
||||||
|
},
|
||||||
|
body2: {
|
||||||
|
fontSize: '0.94rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 24,
|
||||||
|
textTransform: 'none',
|
||||||
|
minHeight: 48, // Touch target size
|
||||||
|
fontSize: '1.07rem', // Larger text
|
||||||
|
fontWeight: 600, // Bolder text for high contrast
|
||||||
|
paddingLeft: 24,
|
||||||
|
paddingRight: 24,
|
||||||
|
},
|
||||||
|
sizeLarge: {
|
||||||
|
minHeight: 56,
|
||||||
|
fontSize: '1.21rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiInputBase-root': {
|
||||||
|
minHeight: 48,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontSize: '1.07rem', // Larger input text
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.12)', // Stronger shadow for contrast
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
elevation1: {
|
||||||
|
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
elevation2: {
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.12)',
|
||||||
|
},
|
||||||
|
elevation3: {
|
||||||
|
boxShadow: '0 6px 30px rgba(0, 0, 0, 0.15)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
fontWeight: 600, // Bolder for high contrast
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
144
maternal-web/styles/themes/purpleTheme.ts
Normal file
144
maternal-web/styles/themes/purpleTheme.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// Purple/Pink gradient theme - New default theme
|
||||||
|
export const purpleTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: '#8b52ff', // Vibrant purple
|
||||||
|
light: '#d194e6', // Light purple
|
||||||
|
dark: '#6b3cc9', // Dark purple
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#ff7094', // Pink
|
||||||
|
light: '#ffb3e4', // Light pink
|
||||||
|
dark: '#e6537a', // Dark pink
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#faf8ff', // Very light purple tint
|
||||||
|
paper: '#ffffff',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#2D3748', // Dark gray - 4.5:1+ contrast
|
||||||
|
secondary: '#4A5568', // Darker gray for better contrast
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: '#10b981',
|
||||||
|
light: '#6ee7b7',
|
||||||
|
dark: '#047857',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
main: '#f59e0b',
|
||||||
|
light: '#fbbf24',
|
||||||
|
dark: '#d97706',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: '#ef4444',
|
||||||
|
light: '#fca5a5',
|
||||||
|
dark: '#dc2626',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
main: '#3b82f6',
|
||||||
|
light: '#93c5fd',
|
||||||
|
dark: '#2563eb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h1: {
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '1.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
body1: {
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
body2: {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 24,
|
||||||
|
textTransform: 'none',
|
||||||
|
minHeight: 48, // Touch target size
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
paddingLeft: 24,
|
||||||
|
paddingRight: 24,
|
||||||
|
},
|
||||||
|
sizeLarge: {
|
||||||
|
minHeight: 56,
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiInputBase-root': {
|
||||||
|
minHeight: 48,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
elevation1: {
|
||||||
|
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.06)',
|
||||||
|
},
|
||||||
|
elevation2: {
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||||
|
},
|
||||||
|
elevation3: {
|
||||||
|
boxShadow: '0 6px 30px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user