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 <meta theme-color> 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 <MotionConfig>; 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 15:50:37 -05:00
parent 1d73c8f13e
commit e2d4a9f703
7 changed files with 179 additions and 34 deletions

View File

@ -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 (
<Sonner
theme={theme}
theme={resolvedTheme}
position="top-right"
closeButton
expand={false}

View File

@ -1,5 +1,5 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Sun, Moon, type LucideIcon } from 'lucide-react';
import { Sun, Moon, Monitor, type LucideIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
@ -54,8 +54,9 @@ interface ThemeOption {
}
const THEMES: ThemeOption[] = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'system', label: 'System', Icon: Monitor },
];
/* ── ThemeToggle ────────────────────────────────────────────── */

View File

@ -0,0 +1,55 @@
import { createContext, useContext, useState, type ReactNode } from 'react';
import { MotionConfig } from 'framer-motion';
/**
* "Reduce motion" device preference, persisted to localStorage under
* 'bt-reduce-motion'. When ON, framer-motion animations are forced off
* (`reducedMotion="always"`); when OFF we pass "user" so the OS
* `prefers-reduced-motion` setting is still honored.
*/
const STORAGE_KEY = 'bt-reduce-motion';
function loadPref(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
}
interface MotionPreferenceValue {
reduceMotion: boolean;
setReduceMotion: (v: boolean) => void;
}
const MotionPreferenceContext = createContext<MotionPreferenceValue | null>(null);
export function MotionPreferenceProvider({ children }: { children: ReactNode }) {
const [reduceMotion, setReduceMotionState] = useState<boolean>(loadPref);
const setReduceMotion = (v: boolean) => {
setReduceMotionState(v);
try {
localStorage.setItem(STORAGE_KEY, String(v));
} catch {
// localStorage unavailable
}
};
return (
<MotionPreferenceContext.Provider value={{ reduceMotion, setReduceMotion }}>
<MotionConfig reducedMotion={reduceMotion ? 'always' : 'user'}>
{children}
</MotionConfig>
</MotionPreferenceContext.Provider>
);
}
export function useMotionPreference() {
const ctx = useContext(MotionPreferenceContext);
if (!ctx) {
throw new Error('useMotionPreference must be used within a MotionPreferenceProvider');
}
return ctx;
}

View File

@ -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<ResolvedTheme, string> = { 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<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
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<ResolvedTheme>(() => 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 (
<ThemeContext.Provider value={{ theme, setTheme }}>
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);

View File

@ -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(
<React.StrictMode>
<ThemeProvider>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
<MotionPreferenceProvider>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
{/* Global shadcn/Sonner toast system */}
<Toaster />
</BrowserRouter>
{/* Global shadcn/Sonner toast system */}
<Toaster />
</BrowserRouter>
</MotionPreferenceProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${label}: help`}
className="ml-1.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-xs leading-relaxed">{text}</TooltipContent>
</Tooltip>
);
}
// ─── 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 (
<SectionCard title="Appearance">
<SettingRow label="Theme" description="Choose your preferred color scheme.">
<SettingRow
label={<span className="inline-flex items-center">Theme<SettingHelp label="Theme" text="“System” follows your device's light/dark setting and switches automatically when it changes." /></span>}
description="Choose your preferred color scheme."
>
<div className="flex gap-2">
<ThemeCard value="light" label="Light" icon={Sun} currentTheme={theme} onSelect={setTheme} />
<ThemeCard value="dark" label="Dark" icon={Moon} currentTheme={theme} onSelect={setTheme} />
<ThemeCard value="light" label="Light" icon={Sun} currentTheme={theme} onSelect={setTheme} />
<ThemeCard value="dark" label="Dark" icon={Moon} currentTheme={theme} onSelect={setTheme} />
<ThemeCard value="system" label="System" icon={Monitor} currentTheme={theme} onSelect={setTheme} />
</div>
</SettingRow>
<SettingRow
label={<span className="inline-flex items-center">Reduce motion<SettingHelp label="Reduce motion" text="Turns off animations and transitions across the app. Also respects your device's reduced-motion setting." /></span>}
description="Minimize animations and transitions."
>
<Switch
checked={reduceMotion}
onCheckedChange={setReduceMotion}
aria-label="Reduce motion"
/>
</SettingRow>
</SectionCard>
);
}
@ -380,6 +418,7 @@ export default function SettingsPage() {
}
return (
<TooltipProvider delayDuration={200}>
<div>
{/* Page header — flat on background, live save status on the right */}
@ -545,5 +584,6 @@ export default function SettingsPage() {
</div>
</div>
</TooltipProvider>
);
}

View File

@ -11,6 +11,24 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="BillTracker">
<title>Bill Tracker</title>
<script>
// Pre-bundle theme apply — avoids a light/dark flash. Mirrors applyTheme in
// client/contexts/ThemeContext.tsx (theme 'system' follows the OS).
(function () {
try {
var s = localStorage.getItem('bt-theme');
if (s === 'dark-purple') s = 'dark';
var theme = (s === 'light' || s === 'dark' || s === 'system') ? s : 'dark';
var resolved = theme === 'system'
? ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light')
: theme;
var root = document.documentElement;
if (resolved === 'dark') root.classList.add('dark'); else root.classList.remove('dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.setAttribute('content', resolved === 'dark' ? '#18181b' : '#ffffff');
} catch (e) { /* localStorage/matchMedia unavailable — keep default */ }
})();
</script>
</head>
<body>
<div id="root"></div>