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';
|
||||
|
||||
export function Toaster() {
|
||||
const { theme } = useTheme();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
theme={resolvedTheme}
|
||||
position="top-right"
|
||||
closeButton
|
||||
expand={false}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -56,6 +56,7 @@ interface ThemeOption {
|
|||
const THEMES: ThemeOption[] = [
|
||||
{ value: 'light', label: 'Light', Icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||
{ value: 'system', label: 'System', Icon: Monitor },
|
||||
];
|
||||
|
||||
/* ── 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';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
// 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;
|
||||
}
|
||||
// 'light' → no classes
|
||||
|
||||
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.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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ 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>
|
||||
<MotionPreferenceProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
|
|
@ -19,6 +21,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||
{/* Global shadcn/Sonner toast system */}
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</MotionPreferenceProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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="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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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-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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue