From e2d4a9f70358860278626cb1814d146c9a25ba9d Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 15:50:37 -0500 Subject: [PATCH] feat(settings): theme "System" + reduce-motion (plan Tier 1) - ThemeContext gains a 'system' theme that follows prefers-color-scheme and updates live when the OS flips; exposes resolvedTheme (consumed by Sonner) and keeps the in sync. Pre-bundle inline script in index.html prevents a light/dark flash. Both theme selectors (Settings ThemeCards + theme-toggle dropdown) gain the System option. - New MotionPreferenceContext mounts framer-motion ; a Reduce-motion toggle (localStorage device pref) forces reducedMotion="always" (else "user", which still respects the OS). - Add an accessible SettingHelp tooltip (focusable button + aria-label) wrapped by a page-level TooltipProvider; used for the System + Reduce-motion explanations. Co-Authored-By: Claude Opus 4.8 --- client/components/ui/sonner.tsx | 4 +- client/components/ui/theme-toggle.tsx | 7 ++- client/contexts/MotionPreferenceContext.tsx | 55 ++++++++++++++++++ client/contexts/ThemeContext.tsx | 64 +++++++++++++++------ client/main.tsx | 17 +++--- client/pages/SettingsPage.tsx | 48 ++++++++++++++-- index.html | 18 ++++++ 7 files changed, 179 insertions(+), 34 deletions(-) create mode 100644 client/contexts/MotionPreferenceContext.tsx diff --git a/client/components/ui/sonner.tsx b/client/components/ui/sonner.tsx index 9e9d7b9..ee5a939 100644 --- a/client/components/ui/sonner.tsx +++ b/client/components/ui/sonner.tsx @@ -2,11 +2,11 @@ import { Toaster as Sonner } from 'sonner'; import { useTheme } from '../../contexts/ThemeContext'; export function Toaster() { - const { theme } = useTheme(); + const { resolvedTheme } = useTheme(); return ( void; +} + +const MotionPreferenceContext = createContext(null); + +export function MotionPreferenceProvider({ children }: { children: ReactNode }) { + const [reduceMotion, setReduceMotionState] = useState(loadPref); + + const setReduceMotion = (v: boolean) => { + setReduceMotionState(v); + try { + localStorage.setItem(STORAGE_KEY, String(v)); + } catch { + // localStorage unavailable + } + }; + + return ( + + + {children} + + + ); +} + +export function useMotionPreference() { + const ctx = useContext(MotionPreferenceContext); + if (!ctx) { + throw new Error('useMotionPreference must be used within a MotionPreferenceProvider'); + } + return ctx; +} diff --git a/client/contexts/ThemeContext.tsx b/client/contexts/ThemeContext.tsx index bca03c8..c02b281 100644 --- a/client/contexts/ThemeContext.tsx +++ b/client/contexts/ThemeContext.tsx @@ -1,27 +1,43 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; /** - * Themes: 'light' | 'dark' + * Themes: 'light' | 'dark' | 'system' * Persisted to localStorage under key 'bt-theme'. * + * 'system' follows the device's `prefers-color-scheme` and updates live when the + * OS flips. `resolvedTheme` is always the concrete 'light' | 'dark' actually + * applied — consume it for anything that needs a real value (e.g. Sonner). + * * Class mapping on document.documentElement: - * light → (no classes) - * dark → 'dark' + * light → (no classes) dark → 'dark' */ -type Theme = 'light' | 'dark'; +type Theme = 'light' | 'dark' | 'system'; +type ResolvedTheme = 'light' | 'dark'; const STORAGE_KEY = 'bt-theme'; -const VALID_THEMES: Theme[] = ['light', 'dark']; +const VALID_THEMES: Theme[] = ['light', 'dark', 'system']; const DEFAULT_THEME: Theme = 'dark'; -function applyTheme(theme: Theme) { +// Keep in sync with the inline pre-bundle script in index.html. +const THEME_COLORS: Record = { light: '#ffffff', dark: '#18181b' }; + +function systemPrefersDark(): boolean { + return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches; +} + +function resolveTheme(theme: Theme): ResolvedTheme { + if (theme === 'system') return systemPrefersDark() ? 'dark' : 'light'; + return theme; +} + +function applyTheme(theme: Theme): ResolvedTheme { + const resolved = resolveTheme(theme); const root = document.documentElement; - root.classList.remove('dark'); - if (theme === 'dark') { - root.classList.add('dark'); - } - // 'light' → no classes + root.classList.toggle('dark', resolved === 'dark'); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute('content', THEME_COLORS[resolved]); + return resolved; } function loadStoredTheme(): Theme { @@ -37,6 +53,7 @@ function loadStoredTheme(): Theme { interface ThemeContextValue { theme: Theme; + resolvedTheme: ResolvedTheme; setTheme: (theme: string) => void; } @@ -45,29 +62,40 @@ const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setThemeState] = useState(() => { const stored = loadStoredTheme(); - // Apply immediately (before first paint) to avoid flash + // Apply immediately (before first paint) to avoid flash. applyTheme(stored); return stored; }); + const [resolvedTheme, setResolvedTheme] = useState(() => resolveTheme(loadStoredTheme())); const setTheme = (newTheme: string) => { if (!(VALID_THEMES as string[]).includes(newTheme)) return; - applyTheme(newTheme as Theme); - setThemeState(newTheme as Theme); + const next = newTheme as Theme; + setResolvedTheme(applyTheme(next)); + setThemeState(next); try { - localStorage.setItem(STORAGE_KEY, newTheme); + localStorage.setItem(STORAGE_KEY, next); } catch { // localStorage unavailable } }; - // Keep DOM in sync if theme state ever changes externally + // Keep DOM in sync if theme state ever changes externally. useEffect(() => { - applyTheme(theme); + setResolvedTheme(applyTheme(theme)); + }, [theme]); + + // While on 'system', re-apply live when the OS light/dark preference flips. + useEffect(() => { + if (theme !== 'system' || typeof window === 'undefined' || !window.matchMedia) return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => setResolvedTheme(applyTheme('system')); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); }, [theme]); return ( - + {children} ); diff --git a/client/main.tsx b/client/main.tsx index 11d7325..b422b25 100644 --- a/client/main.tsx +++ b/client/main.tsx @@ -5,20 +5,23 @@ import App from './App'; import { Toaster } from './components/ui/sonner'; import { AuthProvider } from './hooks/useAuth'; import { ThemeProvider } from './contexts/ThemeContext'; +import { MotionPreferenceProvider } from './contexts/MotionPreferenceContext'; import 'sonner/dist/styles.css'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - - + + + + + - {/* Global shadcn/Sonner toast system */} - - + {/* Global shadcn/Sonner toast system */} + + + ); diff --git a/client/pages/SettingsPage.tsx b/client/pages/SettingsPage.tsx index 7c28c78..316dc9d 100644 --- a/client/pages/SettingsPage.tsx +++ b/client/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, type ComponentType, type ReactNode } import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react'; +import { AlertCircle, Moon, Monitor, RefreshCw, Sun, Users, HelpCircle } from 'lucide-react'; import { api } from '@/api'; import { cn, errMessage, settingEnabled } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -12,7 +12,9 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useTheme } from '@/contexts/ThemeContext'; +import { useMotionPreference } from '@/contexts/MotionPreferenceContext'; import { useAuth } from '@/hooks/useAuth'; import { useAutoSave } from '@/hooks/useAutoSave'; import { SaveStatus } from '@/components/ui/save-status'; @@ -79,6 +81,27 @@ function SettingRow({ label, description, children }: { label: ReactNode; descri ); } +// ─── Inline help tooltip ────────────────────────────────────────────────────── +// A focusable help icon that explains a non-obvious setting. Sits next to a +// SettingRow label. Keyboard + screen-reader accessible (button + aria-label). + +function SettingHelp({ text, label }: { text: string; label: string }) { + return ( + + + + + {text} + + ); +} + // ─── Theme Card ─────────────────────────────────────────────────────────────── function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }: { @@ -111,15 +134,30 @@ function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }: { function AppearanceSection() { const { theme, setTheme } = useTheme(); + const { reduceMotion, setReduceMotion } = useMotionPreference(); return ( - + Theme} + description="Choose your preferred color scheme." + >
- - + + +
+ Reduce motion} + description="Minimize animations and transitions." + > + +
); } @@ -380,6 +418,7 @@ export default function SettingsPage() { } return ( +
{/* Page header — flat on background, live save status on the right */} @@ -545,5 +584,6 @@ export default function SettingsPage() {
+
); } diff --git a/index.html b/index.html index 39c907e..5682975 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,24 @@ Bill Tracker +