openreader/src/components/ColorPicker.tsx
Richard R b7cc2436d2 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.
2026-04-07 01:09:08 -06:00

175 lines
6.6 KiB
TypeScript

'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>
);
}