From 47e4f1b55715c4fc3c3ff694c9e4770077701a55 Mon Sep 17 00:00:00 2001 From: amosleoamadi Date: Fri, 24 Jul 2026 10:28:24 +0100 Subject: [PATCH] feat(theme): add system preference and WCAG contrast improvements --- src/components/Header.tsx | 1 + src/components/StellarHistory.tsx | 2 +- src/context/ThemeContext.tsx | 48 +++++++++++++++++------- src/index.css | 14 +++++-- src/main.tsx | 8 ++-- src/pages/Settings.tsx | 61 +++++++++++++++++++++++++++++++ tailwind.config.ts | 3 ++ 7 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 src/pages/Settings.tsx diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d38cc1c..5942b54 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -16,6 +16,7 @@ export function Header() { { to: '/send', label: t('nav.send') }, { to: '/receive', label: t('nav.receive') }, { to: '/schedule', label: t('nav.schedule') }, + { to: '/settings', label: 'Settings' }, ]; return ( diff --git a/src/components/StellarHistory.tsx b/src/components/StellarHistory.tsx index 9138fd9..f8bc309 100644 --- a/src/components/StellarHistory.tsx +++ b/src/components/StellarHistory.tsx @@ -122,7 +122,7 @@ export function StellarHistory() { tx.status === 'confirmed' ? 'bg-secondary' : tx.status === 'pending' - ? 'bg-tertiary animate-pulse' + ? 'bg-warning animate-pulse' : 'bg-error' }`} /> diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index f574e49..470cbce 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -1,9 +1,12 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; type Theme = 'light' | 'dark'; +export type ThemePreference = Theme | 'system'; interface ThemeContextType { theme: Theme; + preference: ThemePreference; + setThemePreference: (preference: ThemePreference) => void; toggleTheme: () => void; } @@ -11,26 +14,34 @@ const ThemeContext = createContext(undefined); const THEME_STORAGE_KEY = 'wraith-theme'; -function getInitialTheme(): Theme { - if (typeof window === 'undefined') return 'light'; +function getInitialPreference(): ThemePreference { + if (typeof window === 'undefined') return 'system'; const stored = localStorage.getItem(THEME_STORAGE_KEY); - if (stored === 'light' || stored === 'dark') return stored; + return stored === 'light' || stored === 'dark' ? stored : 'system'; +} - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +function getSystemTheme(): Theme { + return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; } export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setTheme] = useState(getInitialTheme); - const [isMounted, setIsMounted] = useState(false); + const [preference, setPreference] = useState(getInitialPreference); + const [systemTheme, setSystemTheme] = useState(getSystemTheme); + const theme = preference === 'system' ? systemTheme : preference; useEffect(() => { - setIsMounted(true); + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (event: MediaQueryListEvent) => { + setSystemTheme(event.matches ? 'dark' : 'light'); + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); }, []); useEffect(() => { - if (!isMounted) return; - const root = document.documentElement; if (theme === 'dark') { root.classList.add('dark'); @@ -38,14 +49,25 @@ export function ThemeProvider({ children }: { children: ReactNode }) { root.classList.remove('dark'); } - localStorage.setItem(THEME_STORAGE_KEY, theme); - }, [theme, isMounted]); + root.style.colorScheme = theme; + if (preference === 'system') { + localStorage.removeItem(THEME_STORAGE_KEY); + } else { + localStorage.setItem(THEME_STORAGE_KEY, preference); + } + }, [theme, preference]); const toggleTheme = () => { - setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); + setPreference(theme === 'light' ? 'dark' : 'light'); }; - return {children}; + return ( + + {children} + + ); } export function useTheme() { diff --git a/src/index.css b/src/index.css index ea4b762..cb2848e 100644 --- a/src/index.css +++ b/src/index.css @@ -6,6 +6,7 @@ /* Light theme (default) */ --color-surface: #ffffff; --color-surface-container: #f5f5f5; + --color-surface-container-high: #eeeeee; --color-surface-bright: #fafafa; --color-primary: #1a1a1a; --color-on-surface: #1a1a1a; @@ -13,10 +14,12 @@ --color-outline: #6b6b6b; --color-outline-variant: #e0e0e0; --color-error: #d32f2f; + --color-warning: #8a5a00; + --color-secondary: #2563eb; --color-tertiary: #2e7d32; --color-selection-bg: #1a1a1a; --color-selection-text: #ffffff; - --color-placeholder: #6b6b6b; + --color-placeholder: #5f5f5f; --color-scrollbar-track: #ffffff; --color-scrollbar-thumb: #c0c0c0; } @@ -25,17 +28,20 @@ /* Dark theme */ --color-surface: #0e0e0e; --color-surface-container: #141414; + --color-surface-container-high: #202020; --color-surface-bright: #1a1a1a; --color-primary: #c6c6c7; --color-on-surface: #e6e1e5; --color-on-surface-variant: #c4c7c5; - --color-outline: #767575; + --color-outline: #a3a3a3; --color-outline-variant: #444444; --color-error: #ee7d77; - --color-tertiary: #22c55e; + --color-warning: #facc15; + --color-secondary: #60a5fa; + --color-tertiary: #4ade80; --color-selection-bg: #c6c6c7; --color-selection-text: #0e0e0e; - --color-placeholder: #767575; + --color-placeholder: #a3a3a3; --color-scrollbar-track: #0e0e0e; --color-scrollbar-thumb: #444444; } diff --git a/src/main.tsx b/src/main.tsx index f30c6cf..e7bd0bd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -103,9 +103,11 @@ function Providers({ children }: { children: React.ReactNode }) { createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..9dd8582 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,61 @@ +import { useTheme, type ThemePreference } from '@/context/ThemeContext'; + +const preferences: Array<{ value: ThemePreference; label: string; description: string }> = [ + { + value: 'system', + label: 'System default', + description: 'Follow your operating system preference.', + }, + { value: 'light', label: 'Light', description: 'Always use the light theme.' }, + { value: 'dark', label: 'Dark', description: 'Always use the dark theme.' }, +]; + +export default function Settings() { + const { preference, setThemePreference } = useTheme(); + + return ( +
+
+ + Preferences + +

+ Settings +

+

+ Choose how Wraith should display colors. System default updates immediately when your OS + theme changes. +

+
+ +
+ + Appearance + + {preferences.map((option) => ( + + ))} +
+
+ ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 9983060..1bf8761 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -9,6 +9,7 @@ const config: Config = { surface: { DEFAULT: 'var(--color-surface)', container: 'var(--color-surface-container)', + 'container-high': 'var(--color-surface-container-high)', bright: 'var(--color-surface-bright)', }, primary: 'var(--color-primary)', @@ -19,6 +20,8 @@ const config: Config = { variant: 'var(--color-outline-variant)', }, error: 'var(--color-error)', + warning: 'var(--color-warning)', + secondary: 'var(--color-secondary)', tertiary: 'var(--color-tertiary)', }, fontFamily: {