From b7cc2436d239e1f44e08118b4514c04c659c91dc Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 7 Apr 2026 01:09:08 -0600 Subject: [PATCH] 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. --- src/app/globals.css | 17 +++ src/components/ColorPicker.tsx | 175 +++++++++++++++++++++++++++++++ src/components/SettingsModal.tsx | 151 +++++++++++++++++++++++--- src/contexts/ThemeContext.tsx | 100 ++++++++++++++++-- 4 files changed, 425 insertions(+), 18 deletions(-) create mode 100644 src/components/ColorPicker.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 24c7cb1..bfce723 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..4dca7ff --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -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 = { + 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(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 ( + + +
+ + + + {/* Label */} +
+ + {label} + + {/* Eyedropper / native picker */} +
+ + onChange(e.target.value)} + className="absolute inset-0 w-full h-full opacity-0 pointer-events-none" + tabIndex={-1} + aria-hidden + /> +
+
+ + {/* Swatch grid */} +
+ {swatches.map((color) => { + const selected = color.toLowerCase() === value.toLowerCase(); + return ( + + ); + })} +
+ + {/* Hex input */} +
+
+ 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" + /> +
+ + + ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 5a535bc..dfe2e5a 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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 = { 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>; authOnly?: boolean }[] = [ @@ -83,7 +94,9 @@ export function SettingsModal({ className = '' }: { className?: string }) { const [isOpen, setIsOpen] = useState(false); const [activeSection, setActiveSection] = useState(enableTTSProvidersTab ? 'api' : 'theme'); - const { theme, setTheme } = useTheme(); + const { theme, setTheme, applyCustomColors } = useTheme(); + const [customColors, setCustomColors] = useState(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 (
+ {/* Custom theme */} +
+ + {(() => { + const colors = getThemeColors('custom'); + const isActive = theme === 'custom'; + return ( +
+
+ + +
+ + {isCustomExpanded && ( +
+
+ {CUSTOM_COLOR_FIELDS.map(({ key, label }) => ( +
+ + {label} + + + {customColors[key]} + + { + const updated = { ...customColors, [key]: color }; + setCustomColors(updated); + applyCustomColors(updated); + }} + /> +
+ ))} +
+
+ )} +
+ ); + })()} +
+ {/* Light themes */}
-
+
{lightThemes.map((t) => { const colors = getThemeColors(t.id); const isActive = theme === t.id; @@ -734,7 +861,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {