feat(theme): add persistent custom theme with live color editing

Add support for a persistent "custom" theme that users can edit and preview in settings.

- add CustomThemeColors type and storage helpers (getCustomThemeColors, setCustomThemeColors)
- apply custom CSS variables at runtime and provide applyCustomColors to live-update variables
- include fallback CSS for html.custom to avoid flash before hydration
- add ColorPicker component with curated swatches for each color role
- extend SettingsModal to support editing, previewing, and persisting custom theme colors; exclude 'custom' from regular theme lists and read custom colors when rendering previews
- tighten theme detection logic to treat custom background luminance as light/dark when needed

This enables users to define, persist, and see live updates for custom color palettes.
This commit is contained in:
Richard R 2026-04-07 01:09:08 -06:00
parent f9237f9270
commit b7cc2436d2
4 changed files with 425 additions and 18 deletions

View file

@ -184,6 +184,23 @@ html.slate {
);
}
/* Custom theme: variables are set via inline styles from JS/localStorage.
These fallback values are only used before JS hydrates. */
html.custom {
--background: #1a1a2e;
--foreground: #e0e0e0;
--base: #16213e;
--offbase: #0f3460;
--accent: #e94560;
--secondary-accent: #f78da7;
--muted: #7f8c8d;
--prism-gradient: linear-gradient(90deg,
#e94560,
#f78da7,
#7f8c8d
);
}
body {
color: var(--foreground);
background: var(--background);

View file

@ -0,0 +1,175 @@
'use client';
import { useRef, useState, useEffect, useCallback } from 'react';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons';
/**
* Curated swatch palettes per color role, sourced from existing themes
* plus hand-picked pastels and bold tones for variety.
*/
/* 18 swatches each → 3 clean rows of 6 */
const ROLE_SWATCHES: Record<keyof CustomThemeColors, string[]> = {
background: [
'#111111', '#020617', '#0a0f0c', '#1a0f0f', '#0c1922', '#0f1916',
'#ffffff', '#faf8ff', '#fff8f8', '#fdfbf7', '#f6faff', '#e8ecf0',
'#fef3c7', '#fce7f3', '#e0e7ff', '#d1fae5', '#f5f3ff', '#fff1f2',
],
base: [
'#171717', '#0f172a', '#111a15', '#2c1810', '#102c3d', '#132d27',
'#f7fafc', '#f3effb', '#fef1f1', '#f7f2e8', '#edf4fc', '#dde2e8',
'#fefce8', '#fdf2f8', '#eef2ff', '#ecfdf5', '#faf5ff', '#fff5f5',
],
offbase: [
'#343434', '#1e293b', '#1a2820', '#3d1f14', '#1a3c52', '#1c3d35',
'#e2e8f0', '#e4daf0', '#f5dada', '#e8dfc9', '#d5e3f5', '#c8ced6',
'#fde68a', '#fbcfe8', '#c7d2fe', '#a7f3d0', '#e9d5ff', '#fecdd3',
],
accent: [
'#ef4444', '#f87171', '#38bdf8', '#4ade80', '#ff6b6b', '#06b6d4',
'#2dd4bf', '#7c3aed', '#e11d48', '#b45309', '#2563eb', '#e94560',
'#f59e0b', '#ec4899', '#8b5cf6', '#10b981', '#f97316', '#5b7a9d',
],
secondaryAccent: [
'#ed6868', '#eb6262', '#22d3ee', '#22c55e', '#f59e0b', '#0ea5e9',
'#10b981', '#a78bfa', '#f472b6', '#d97706', '#3b82f6', '#f78da7',
'#fbbf24', '#f9a8d4', '#34d399', '#fb923c', '#7393b0', '#c084fc',
],
foreground: [
'#2d3748', '#ededed', '#e2e8f0', '#d4e8d0', '#ffe4d6', '#e0f2fe',
'#dcfce7', '#3b2e5a', '#4a2c2c', '#44392a', '#1e3a5f', '#2c3440',
'#1c1917', '#18181b', '#1e293b', '#064e3b', '#4c1d95', '#881337',
],
muted: [
'#718096', '#a3a3a3', '#94a3b8', '#7c8f85', '#bc8f8f', '#7ca7c4',
'#75a99c', '#8e7bab', '#b08a8a', '#9a8b74', '#6b8db5', '#7a8694',
'#d4d4d8', '#a1a1aa', '#78716c', '#6b7280', '#9ca3af', '#64748b',
],
};
interface ColorPickerProps {
value: string;
field: keyof CustomThemeColors;
label: string;
onChange: (color: string) => void;
}
export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) {
const [hexInput, setHexInput] = useState(value);
const nativeRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setHexInput(value);
}, [value]);
const handleHexCommit = useCallback((raw: string) => {
let hex = raw.trim();
if (!hex.startsWith('#')) hex = '#' + hex;
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
onChange(hex.toLowerCase());
} else {
// revert to current value
setHexInput(value);
}
}, [onChange, value]);
const swatches = ROLE_SWATCHES[field];
return (
<Popover className="relative flex items-center">
<PopoverButton className="cursor-pointer group focus:outline-none">
<div
className="w-6 h-6 rounded-full border-2 transition-all duration-150 group-hover:scale-110 group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
style={{
backgroundColor: value,
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className="z-[60] mt-2 w-56 rounded-xl shadow-xl border border-offbase bg-background p-3 space-y-3
transition duration-150 ease-out data-[closed]:opacity-0 data-[closed]:scale-95"
>
{/* Label */}
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
{label}
</span>
{/* Eyedropper / native picker */}
<div className="relative">
<button
type="button"
onClick={() => nativeRef.current?.click()}
className="p-1"
aria-label="Open system color picker"
>
<PaletteIcon className="w-4 h-4 text-muted transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
<input
ref={nativeRef}
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 w-full h-full opacity-0 pointer-events-none"
tabIndex={-1}
aria-hidden
/>
</div>
</div>
{/* Swatch grid */}
<div className="grid grid-cols-6 gap-1.5">
{swatches.map((color) => {
const selected = color.toLowerCase() === value.toLowerCase();
return (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-100 hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
style={{
backgroundColor: color,
boxShadow: selected ? '0 0 0 2px var(--background), 0 0 0 4px var(--foreground)' : undefined,
}}
aria-label={color}
>
{selected && (
<svg className="absolute inset-0 m-auto w-2.5 h-2.5" viewBox="0 0 24 24" fill="none" stroke={isLightColor(color) ? '#000' : '#fff'} strokeWidth={3.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
);
})}
</div>
{/* Hex input */}
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-lg border shrink-0"
style={{
backgroundColor: value,
borderColor: isLightColor(value) ? '#00000018' : '#ffffff18',
}}
/>
<input
type="text"
value={hexInput}
onChange={(e) => setHexInput(e.target.value)}
onBlur={(e) => handleHexCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleHexCommit(hexInput);
}}
spellCheck={false}
maxLength={7}
className="flex-1 rounded-lg px-2 py-1 text-xs font-mono border border-offbase bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</PopoverPanel>
</Popover>
);
}

View file

@ -23,7 +23,8 @@ import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { THEMES } from '@/contexts/ThemeContext';
import { THEMES, getCustomThemeColors, type CustomThemeColors } from '@/contexts/ThemeContext';
import { ColorPicker } from '@/components/ColorPicker';
import { DocumentSelectionModal } from '@/components/documents/DocumentSelectionModal';
import { BaseDocument } from '@/types/documents';
import { getAuthClient } from '@/lib/client/auth-client';
@ -61,7 +62,7 @@ const THEME_COLORS: Record<string, ThemeColorSet> = {
const LIGHT_THEME_IDS = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
const allThemes = THEMES.map(id => ({
const allThemes = THEMES.filter(id => id !== 'custom').map(id => ({
id,
name: id.charAt(0).toUpperCase() + id.slice(1),
}));
@ -70,6 +71,16 @@ const systemTheme = allThemes.find(t => t.id === 'system')!;
const lightThemes = allThemes.filter(t => LIGHT_THEME_IDS.has(t.id));
const darkThemes = allThemes.filter(t => t.id !== 'system' && !LIGHT_THEME_IDS.has(t.id));
const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
{ key: 'background', label: 'Background' },
{ key: 'base', label: 'Base' },
{ key: 'offbase', label: 'Off-base' },
{ key: 'accent', label: 'Accent' },
{ key: 'secondaryAccent', label: 'Accent 2' },
{ key: 'foreground', label: 'Foreground' },
{ key: 'muted', label: 'Muted' },
];
type SectionId = 'api' | 'theme' | 'docs' | 'account';
const SIDEBAR_SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; authOnly?: boolean }[] = [
@ -83,7 +94,9 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const [isOpen, setIsOpen] = useState(false);
const [activeSection, setActiveSection] = useState<SectionId>(enableTTSProvidersTab ? 'api' : 'theme');
const { theme, setTheme } = useTheme();
const { theme, setTheme, applyCustomColors } = useTheme();
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
const { refreshDocuments } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
@ -344,8 +357,19 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const getThemeColors = useCallback((id: string): ThemeColorSet => {
if (id === 'system') return THEME_COLORS[systemIsDark ? 'dark' : 'light'];
if (id === 'custom') {
return {
background: customColors.background,
base: customColors.base,
offbase: customColors.offbase,
accent: customColors.accent,
secondaryAccent: customColors.secondaryAccent,
foreground: customColors.foreground,
muted: customColors.muted,
};
}
return THEME_COLORS[id] || THEME_COLORS.light;
}, [systemIsDark]);
}, [systemIsDark, customColors]);
const visibleSections = useMemo(
() => SIDEBAR_SECTIONS.filter((section) => {
@ -693,7 +717,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
return (
<button
onClick={() => setTheme(systemTheme.id)}
className={`flex items-center gap-3 rounded-lg px-3 py-1.5 w-full text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 w-full text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
@ -706,7 +730,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-16 shrink-0"
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{systemTheme.name}
@ -723,10 +747,113 @@ export function SettingsModal({ className = '' }: { className?: string }) {
})()}
</div>
{/* Custom theme */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Custom</label>
{(() => {
const colors = getThemeColors('custom');
const isActive = theme === 'custom';
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<button
onClick={() => {
setTheme('custom');
setIsCustomExpanded(true);
}}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 flex-1 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
Custom
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
<button
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
className="shrink-0 p-1.5 rounded-lg border border-offbase hover:border-muted transition-colors"
style={{ color: colors.muted, backgroundColor: colors.base }}
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
>
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{isCustomExpanded && (
<div
className="rounded-lg border p-3 space-y-2"
style={{
backgroundColor: colors.background,
borderColor: isActive ? colors.accent : colors.offbase,
}}
>
<div className="flex flex-col gap-1">
{CUSTOM_COLOR_FIELDS.map(({ key, label }) => (
<div
key={key}
className="grid items-center rounded-md px-2 py-1"
style={{
backgroundColor: colors.base,
gridTemplateColumns: '5rem 1fr auto',
gap: '0.5rem',
}}
>
<span
className="text-xs font-medium truncate"
style={{ color: colors.foreground }}
>
{label}
</span>
<span
className="text-[10px] font-mono text-right"
style={{ color: colors.muted }}
>
{customColors[key]}
</span>
<ColorPicker
value={customColors[key]}
field={key}
label={label}
onChange={(color) => {
const updated = { ...customColors, [key]: color };
setCustomColors(updated);
applyCustomColors(updated);
}}
/>
</div>
))}
</div>
</div>
)}
</div>
);
})()}
</div>
{/* Light themes */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Light</label>
<div className="grid grid-cols-2 gap-1.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{lightThemes.map((t) => {
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
@ -734,7 +861,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={`flex items-center gap-3 rounded-lg px-3 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
@ -747,7 +874,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-16 shrink-0"
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
@ -768,7 +895,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{/* Dark themes */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Dark</label>
<div className="grid grid-cols-2 gap-1.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{darkThemes.map((t) => {
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
@ -776,7 +903,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={`flex items-center gap-3 rounded-lg px-3 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
@ -789,7 +916,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-16 shrink-0"
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}

View file

@ -2,12 +2,77 @@
import { createContext, useContext, useEffect, useState, ReactNode, useLayoutEffect } from 'react';
const THEMES = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate'] as const;
const THEMES = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate', 'custom'] as const;
type Theme = (typeof THEMES)[number];
export interface CustomThemeColors {
background: string;
foreground: string;
base: string;
offbase: string;
accent: string;
secondaryAccent: string;
muted: string;
}
const CUSTOM_THEME_STORAGE_KEY = 'customThemeColors';
const DEFAULT_CUSTOM_COLORS: CustomThemeColors = {
background: '#1a1a2e',
foreground: '#e0e0e0',
base: '#16213e',
offbase: '#0f3460',
accent: '#e94560',
secondaryAccent: '#f78da7',
muted: '#7f8c8d',
};
export function getCustomThemeColors(): CustomThemeColors {
if (typeof window === 'undefined') return DEFAULT_CUSTOM_COLORS;
try {
const stored = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
if (stored) return { ...DEFAULT_CUSTOM_COLORS, ...JSON.parse(stored) };
} catch { /* ignore */ }
return DEFAULT_CUSTOM_COLORS;
}
export function setCustomThemeColors(colors: CustomThemeColors): void {
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, JSON.stringify(colors));
}
function applyCustomThemeVariables(colors: CustomThemeColors): void {
const root = document.documentElement;
root.style.setProperty('--background', colors.background);
root.style.setProperty('--foreground', colors.foreground);
root.style.setProperty('--base', colors.base);
root.style.setProperty('--offbase', colors.offbase);
root.style.setProperty('--accent', colors.accent);
root.style.setProperty('--secondary-accent', colors.secondaryAccent);
root.style.setProperty('--muted', colors.muted);
root.style.setProperty('--prism-gradient', `linear-gradient(90deg, ${colors.accent}, ${colors.secondaryAccent}, ${colors.muted})`);
}
function clearCustomThemeVariables(): void {
const root = document.documentElement;
const vars = ['--background', '--foreground', '--base', '--offbase', '--accent', '--secondary-accent', '--muted', '--prism-gradient'];
vars.forEach(v => root.style.removeProperty(v));
}
/** Returns true if the hex color is perceptually light (luminance > 0.5) */
function isLightColor(hex: string): boolean {
const c = hex.replace('#', '');
const r = parseInt(c.substring(0, 2), 16) / 255;
const g = parseInt(c.substring(2, 4), 16) / 255;
const b = parseInt(c.substring(4, 6), 16) / 255;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
return luminance > 0.5;
}
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
/** Call to live-update custom theme colors while the custom theme is active */
applyCustomColors: (colors: CustomThemeColors) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@ -17,7 +82,7 @@ const getSystemTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const LIGHT_THEMES: ReadonlySet<string> = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
const STATIC_LIGHT_THEMES: ReadonlySet<string> = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
const getEffectiveTheme = (theme: Theme): Theme => {
if (theme === 'system') {
@ -27,7 +92,10 @@ const getEffectiveTheme = (theme: Theme): Theme => {
};
const getColorScheme = (theme: Theme): string => {
return LIGHT_THEMES.has(theme) ? 'light' : 'dark';
if (theme === 'custom') {
return isLightColor(getCustomThemeColors().background) ? 'light' : 'dark';
}
return STATIC_LIGHT_THEMES.has(theme) ? 'light' : 'dark';
};
export function ThemeProvider({ children }: { children: ReactNode }) {
@ -43,6 +111,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
document.documentElement.classList.remove(...THEMES);
document.documentElement.classList.add(effectiveTheme);
document.documentElement.style.colorScheme = getColorScheme(effectiveTheme);
if (effectiveTheme === 'custom') {
applyCustomThemeVariables(getCustomThemeColors());
}
if (!stored) {
localStorage.setItem('theme', initialTheme);
}
@ -52,15 +123,32 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const handleThemeChange = (newTheme: Theme) => {
const root = window.document.documentElement;
const effectiveTheme = getEffectiveTheme(newTheme);
// Clear inline vars from a previous custom theme before switching
if (theme === 'custom' && effectiveTheme !== 'custom') {
clearCustomThemeVariables();
}
root.classList.remove(...THEMES);
root.classList.add(effectiveTheme);
root.style.colorScheme = getColorScheme(effectiveTheme);
if (effectiveTheme === 'custom') {
applyCustomThemeVariables(getCustomThemeColors());
}
localStorage.setItem('theme', newTheme);
setTheme(newTheme);
};
const handleApplyCustomColors = (colors: CustomThemeColors) => {
setCustomThemeColors(colors);
if (theme === 'custom') {
applyCustomThemeVariables(colors);
document.documentElement.style.colorScheme = isLightColor(colors.background) ? 'light' : 'dark';
}
};
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
@ -84,7 +172,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
return (
<ThemeContext.Provider value={{ theme, setTheme: handleThemeChange }}>
<ThemeContext.Provider value={{ theme, setTheme: handleThemeChange, applyCustomColors: handleApplyCustomColors }}>
{children}
</ThemeContext.Provider>
);
@ -98,4 +186,4 @@ export function useTheme() {
return context;
}
export { THEMES };
export { THEMES, isLightColor };