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:
parent
1d73c8f13e
commit
e2d4a9f703
|
|
@ -2,11 +2,11 @@ import { Toaster as Sonner } from 'sonner';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { theme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme}
|
theme={resolvedTheme}
|
||||||
position="top-right"
|
position="top-right"
|
||||||
closeButton
|
closeButton
|
||||||
expand={false}
|
expand={false}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
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 type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
@ -54,8 +54,9 @@ interface ThemeOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEMES: ThemeOption[] = [
|
const THEMES: ThemeOption[] = [
|
||||||
{ value: 'light', label: 'Light', Icon: Sun },
|
{ value: 'light', label: 'Light', Icon: Sun },
|
||||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||||
|
{ value: 'system', label: 'System', Icon: Monitor },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── ThemeToggle ────────────────────────────────────────────── */
|
/* ── ThemeToggle ────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,43 @@
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themes: 'light' | 'dark'
|
* Themes: 'light' | 'dark' | 'system'
|
||||||
* Persisted to localStorage under key 'bt-theme'.
|
* 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:
|
* Class mapping on document.documentElement:
|
||||||
* light → (no classes)
|
* light → (no classes) dark → 'dark'
|
||||||
* dark → 'dark'
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
const STORAGE_KEY = 'bt-theme';
|
const STORAGE_KEY = 'bt-theme';
|
||||||
const VALID_THEMES: Theme[] = ['light', 'dark'];
|
const VALID_THEMES: Theme[] = ['light', 'dark', 'system'];
|
||||||
const DEFAULT_THEME: Theme = 'dark';
|
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;
|
const root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
root.classList.toggle('dark', resolved === 'dark');
|
||||||
if (theme === 'dark') {
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
root.classList.add('dark');
|
if (meta) meta.setAttribute('content', THEME_COLORS[resolved]);
|
||||||
}
|
return resolved;
|
||||||
// 'light' → no classes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStoredTheme(): Theme {
|
function loadStoredTheme(): Theme {
|
||||||
|
|
@ -37,6 +53,7 @@ function loadStoredTheme(): Theme {
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
setTheme: (theme: string) => void;
|
setTheme: (theme: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,29 +62,40 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<Theme>(() => {
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
const stored = loadStoredTheme();
|
const stored = loadStoredTheme();
|
||||||
// Apply immediately (before first paint) to avoid flash
|
// Apply immediately (before first paint) to avoid flash.
|
||||||
applyTheme(stored);
|
applyTheme(stored);
|
||||||
return stored;
|
return stored;
|
||||||
});
|
});
|
||||||
|
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(loadStoredTheme()));
|
||||||
|
|
||||||
const setTheme = (newTheme: string) => {
|
const setTheme = (newTheme: string) => {
|
||||||
if (!(VALID_THEMES as string[]).includes(newTheme)) return;
|
if (!(VALID_THEMES as string[]).includes(newTheme)) return;
|
||||||
applyTheme(newTheme as Theme);
|
const next = newTheme as Theme;
|
||||||
setThemeState(newTheme as Theme);
|
setResolvedTheme(applyTheme(next));
|
||||||
|
setThemeState(next);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage unavailable
|
// localStorage unavailable
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep DOM in sync if theme state ever changes externally
|
// Keep DOM in sync if theme state ever changes externally.
|
||||||
useEffect(() => {
|
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]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,23 @@ import App from './App';
|
||||||
import { Toaster } from './components/ui/sonner';
|
import { Toaster } from './components/ui/sonner';
|
||||||
import { AuthProvider } from './hooks/useAuth';
|
import { AuthProvider } from './hooks/useAuth';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import { MotionPreferenceProvider } from './contexts/MotionPreferenceContext';
|
||||||
import 'sonner/dist/styles.css';
|
import 'sonner/dist/styles.css';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<MotionPreferenceProvider>
|
||||||
<AuthProvider>
|
<BrowserRouter>
|
||||||
<App />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
|
||||||
{/* Global shadcn/Sonner toast system */}
|
{/* Global shadcn/Sonner toast system */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</MotionPreferenceProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, type ComponentType, type ReactNode }
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
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 { api } from '@/api';
|
||||||
import { cn, errMessage, settingEnabled } from '@/lib/utils';
|
import { cn, errMessage, settingEnabled } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import { useMotionPreference } from '@/contexts/MotionPreferenceContext';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||||
import { SaveStatus } from '@/components/ui/save-status';
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Theme Card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }: {
|
function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }: {
|
||||||
|
|
@ -111,15 +134,30 @@ function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }: {
|
||||||
|
|
||||||
function AppearanceSection() {
|
function AppearanceSection() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { reduceMotion, setReduceMotion } = useMotionPreference();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Appearance">
|
<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">
|
<div className="flex gap-2">
|
||||||
<ThemeCard value="light" label="Light" icon={Sun} 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="dark" label="Dark" icon={Moon} currentTheme={theme} onSelect={setTheme} />
|
||||||
|
<ThemeCard value="system" label="System" icon={Monitor} currentTheme={theme} onSelect={setTheme} />
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</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>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -380,6 +418,7 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{/* Page header — flat on background, live save status on the right */}
|
{/* Page header — flat on background, live save status on the right */}
|
||||||
|
|
@ -545,5 +584,6 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
index.html
18
index.html
|
|
@ -11,6 +11,24 @@
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="BillTracker">
|
<meta name="apple-mobile-web-app-title" content="BillTracker">
|
||||||
<title>Bill Tracker</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue