/** * Theme switcher — preset selection + custom token editor. * * Presets: * Default — Linear / Vercel dual-color (index.css :root / .dark) * Custom — user-editable palette, stored in localStorage * * The panel is opened via the sidebar "Theme" button and presented as a Sheet. */ import { useCallback, useEffect, useState } from 'react'; import { applyPaletteToDOM, clearCustomPalette, encodeThemeToken, decodeThemeToken, isThemeToken, loadActivePresetId, loadCustomPalette, resetDOMFromPalette, saveActivePresetId, saveCustomPalette, THEME_PRESETS, } from './design-system'; import type { PaletteEntry, ThemePresetId } from './design-system'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { cn } from '@/lib/utils'; import { useTheme } from '@/contexts'; import { Check, Copy, Download, RotateCcw, Sliders } from 'lucide-react'; import {LanguageSwitcher} from '@/components/shared/LanguageSwitcher'; // ─── Token definitions ─────────────────────────────────────────────────────── interface TokenDef { key: keyof PaletteEntry; label: string; group: 'surface' | 'text' | 'accent' | 'border' | 'status' | 'message'; type: 'color'; } const TOKEN_DEFS: TokenDef[] = [ // Surface { key: 'bg', label: 'Background', group: 'surface', type: 'color' }, { key: 'bgSubtle', label: 'Subtle', group: 'surface', type: 'color' }, { key: 'bgHover', label: 'Hover', group: 'surface', type: 'color' }, { key: 'bgActive', label: 'Active', group: 'surface', type: 'color' }, { key: 'surface', label: 'Surface (card)', group: 'surface', type: 'color' }, { key: 'surface2', label: 'Surface 2', group: 'surface', type: 'color' }, { key: 'panelBg', label: 'Panel / Sidebar', group: 'surface', type: 'color' }, // Text { key: 'text', label: 'Text', group: 'text', type: 'color' }, { key: 'textMuted', label: 'Text Muted', group: 'text', type: 'color' }, { key: 'textSubtle', label: 'Text Subtle', group: 'text', type: 'color' }, { key: 'icon', label: 'Icon', group: 'text', type: 'color' }, { key: 'iconHover', label: 'Icon Hover', group: 'text', type: 'color' }, // Accent { key: 'accent', label: 'Accent', group: 'accent', type: 'color' }, { key: 'accentHover', label: 'Accent Hover', group: 'accent', type: 'color' }, { key: 'accentText', label: 'Accent Text', group: 'accent', type: 'color' }, { key: 'mentionBg', label: 'Mention BG', group: 'accent', type: 'color' }, { key: 'mentionText', label: 'Mention Text', group: 'accent', type: 'color' }, // Border { key: 'border', label: 'Border', group: 'border', type: 'color' }, { key: 'borderFocus', label: 'Border Focus', group: 'border', type: 'color' }, { key: 'borderMuted', label: 'Border Muted', group: 'border', type: 'color' }, // Status { key: 'online', label: 'Online', group: 'status', type: 'color' }, { key: 'away', label: 'Away', group: 'status', type: 'color' }, { key: 'offline', label: 'Offline', group: 'status', type: 'color' }, // Message { key: 'msgBg', label: 'Message BG', group: 'message', type: 'color' }, { key: 'msgOwnBg', label: 'Own Message BG', group: 'message', type: 'color' }, ]; const GROUP_LABELS: Record = { surface: 'Surface', text: 'Text & Icon', accent: 'Accent', border: 'Border', status: 'Status', message: 'Message', }; // ─── Preset Card ───────────────────────────────────────────────────────────── function PresetCard({ preset, active, onClick, }: { preset: (typeof THEME_PRESETS)[number]; active: boolean; onClick: () => void; }) { const { resolvedTheme } = useTheme(); const previewPalette = preset.palette ?? buildDefaultPreview(resolvedTheme); const swatches = [ previewPalette.bg, previewPalette.surface, previewPalette.border, previewPalette.text, previewPalette.textMuted, previewPalette.accent, previewPalette.accentText, ]; return ( ); } function buildDefaultPreview(theme: 'light' | 'dark'): PaletteEntry { if (theme === 'dark') { return { bg: '#1a1a1e', bgSubtle: '#1e1e23', bgHover: '#222228', bgActive: '#2a2a30', border: '#2e2e35', borderFocus: '#4a9eff', borderMuted: '#252528', text: '#ececf1', textMuted: '#8a8a92', textSubtle: '#5c5c65', accent: '#4a9eff', accentHover: '#6aafff', accentText: '#ffffff', icon: '#7a7a84', iconHover: '#b0b0ba', surface: '#222228', surface2: '#2a2a30', online: '#34d399', away: '#fbbf24', offline: '#6b7280', mentionBg: 'rgba(74,158,255,0.12)', mentionText: '#4a9eff', msgBg: '#1e1e23', msgOwnBg: '#1a2a3a', panelBg: '#161619', badgeAi: '', badgeRole: '', }; } return { bg: '#ffffff', bgSubtle: '#f9f9fa', bgHover: '#f3f3f5', bgActive: '#ebebef', border: '#e4e4e8', borderFocus: '#1c7ded', borderMuted: '#eeeeef', text: '#1f1f1f', textMuted: '#8a8a8f', textSubtle: '#b8b8bd', accent: '#1c7ded', accentHover: '#1a73d4', accentText: '#ffffff', icon: '#8a8a8f', iconHover: '#5c5c62', surface: '#f7f7f8', surface2: '#eeeeef', online: '#22c55e', away: '#f59e0b', offline: '#d1d1d6', mentionBg: 'rgba(28,125,237,0.08)', mentionText: '#1c7ded', msgBg: '#f9f9fb', msgOwnBg: '#e8f0fe', panelBg: '#f9f9fa', badgeAi: '', badgeRole: '', }; } // ─── Token Editor ───────────────────────────────────────────────────────────── function TokenEditor({ value, onChange, }: { value: PaletteEntry; onChange: (v: PaletteEntry) => void; }) { const groups = (['surface', 'text', 'accent', 'border', 'status', 'message'] as const); return (
{groups.map((group) => { const defs = TOKEN_DEFS.filter((d) => d.group === group); return (

{GROUP_LABELS[group]}

{defs.map((def) => (
{/* Swatch + native color picker */}
onChange({ ...value, [def.key]: e.target.value }) } className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" title={def.label} />

{def.label}

onChange({ ...value, [def.key]: e.target.value }) } className="w-full bg-transparent border-0 p-0 text-[10px] text-muted-foreground focus:outline-none focus:ring-0 font-mono" spellCheck={false} />
))}
); })}
); } // ─── Main component ─────────────────────────────────────────────────────────── interface ThemeSwitcherProps { open: boolean; onOpenChange: (open: boolean) => void; } export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) { const { resolvedTheme } = useTheme(); const [activePresetId, setActivePresetId] = useState(loadActivePresetId); const [customPalette, setCustomPalette] = useState( loadCustomPalette, ); // Working copy being edited const [draft, setDraft] = useState(null); const [isDirty, setIsDirty] = useState(false); // Token import const [tokenInput, setTokenInput] = useState(''); const [tokenError, setTokenError] = useState(''); const [copied, setCopied] = useState(false); // Reset when panel opens useEffect(() => { if (open) { const id = loadActivePresetId(); setActivePresetId(id); setCustomPalette(loadCustomPalette()); setDraft(id === 'custom' && loadCustomPalette() ? { ...loadCustomPalette()! } : null); setIsDirty(false); } }, [open]); const applyPreset = useCallback( (presetId: ThemePresetId) => { setActivePresetId(presetId); saveActivePresetId(presetId); if (presetId === 'custom') { const stored = loadCustomPalette(); setCustomPalette(stored); setDraft(stored ? { ...stored } : null); if (stored) applyPaletteToDOM(stored); } else { clearCustomPalette(); setCustomPalette(null); setDraft(null); resetDOMFromPalette(); } setIsDirty(false); }, [], ); const handleDraftChange = useCallback((next: PaletteEntry) => { setDraft(next); setIsDirty(true); }, []); const handleApplyCustom = useCallback(() => { if (!draft) return; saveCustomPalette(draft); setCustomPalette(draft); applyPaletteToDOM(draft); setActivePresetId('custom'); saveActivePresetId('custom'); setIsDirty(false); }, [draft]); const handleReset = useCallback(() => { applyPreset('default'); }, [applyPreset]); // Get the current theme's token for sharing const currentToken = (() => { if (activePresetId === 'custom' && customPalette) { return encodeThemeToken(customPalette); } const preset = THEME_PRESETS.find((p) => p.id === activePresetId); if (preset?.palette) { return encodeThemeToken(preset.palette); } return null; })(); const handleCopyToken = useCallback(() => { if (!currentToken) return; navigator.clipboard.writeText(currentToken).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [currentToken]); const handleImportToken = useCallback(() => { const trimmed = tokenInput.trim(); if (!trimmed) { setTokenError('Please paste a theme token'); return; } if (!isThemeToken(trimmed)) { setTokenError('Invalid token format'); return; } const palette = decodeThemeToken(trimmed); if (!palette) { setTokenError('Could not decode token'); return; } setTokenError(''); setTokenInput(''); // Apply as custom palette saveCustomPalette(palette); saveActivePresetId('custom'); applyPaletteToDOM(palette); setActivePresetId('custom'); setCustomPalette(palette); setDraft({ ...palette }); setIsDirty(false); }, [tokenInput]); return ( Theme Settings
{/* ── Scrollable content with padding ─────────────────────────────── */}
{/* ── Preset grid ─────────────────────────────────────────────────── */}

Presets

{THEME_PRESETS.map((preset) => ( applyPreset(preset.id as ThemePresetId)} /> ))} {/* Custom preset card — always shown */}
{/* ── Share / Import Token ────────────────────────────────────────── */}

Share & Import

{/* Current token display + copy */} {currentToken && (

Current theme token

)} {/* Import token */}

Import a theme token

{ setTokenInput(e.target.value); setTokenError(''); }} placeholder="T1:eyJi..." className="flex-1 min-w-0 rounded border bg-background px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring" spellCheck={false} />
{tokenError && (

{tokenError}

)}
{/* ── Custom token editor ─────────────────────────────────────────── */} {activePresetId === 'custom' && draft && (

Token Editor

)}
); }