feat(theme): add preset color schemes and theme token system
Add 9 preset themes (Midnight, Forest, Sunset, Rose, Lavender, Arctic, Nord, Dracula, Clean Light) with full palette definitions. Implement theme token encoding/decoding algorithm: - Token format: "T1:" + base64url(minified JSON) - Share any theme via copyable token string - Import themes by pasting tokens - Only 16 core color fields encoded (badge classes derived)
This commit is contained in:
parent
907b5ee3bf
commit
867f216a1f
@ -12,6 +12,9 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
applyPaletteToDOM,
|
||||
clearCustomPalette,
|
||||
encodeThemeToken,
|
||||
decodeThemeToken,
|
||||
isThemeToken,
|
||||
loadActivePresetId,
|
||||
loadCustomPalette,
|
||||
resetDOMFromPalette,
|
||||
@ -24,7 +27,7 @@ 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, RotateCcw, Sliders } from 'lucide-react';
|
||||
import { Check, Copy, Download, RotateCcw, Sliders } from 'lucide-react';
|
||||
import {LanguageSwitcher} from '@/components/shared/LanguageSwitcher';
|
||||
|
||||
// ─── Token definitions ───────────────────────────────────────────────────────
|
||||
@ -246,6 +249,10 @@ export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
|
||||
// Working copy being edited
|
||||
const [draft, setDraft] = useState<PaletteEntry | null>(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(() => {
|
||||
@ -297,6 +304,53 @@ export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col overflow-y-auto w-[360px] sm:max-w-[360px]">
|
||||
@ -371,6 +425,63 @@ export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Share / Import Token ────────────────────────────────────────── */}
|
||||
<div className="border-t pt-5 space-y-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Share & Import
|
||||
</p>
|
||||
|
||||
{/* Current token display + copy */}
|
||||
{currentToken && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] text-muted-foreground">Current theme token</p>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={currentToken}
|
||||
className="flex-1 min-w-0 rounded border bg-muted/50 px-2 py-1.5 text-[10px] font-mono text-muted-foreground truncate focus:outline-none"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs shrink-0"
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import token */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] text-muted-foreground">Import a theme token</p>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={tokenInput}
|
||||
onChange={(e) => { 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}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs shrink-0"
|
||||
onClick={handleImportToken}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
{tokenError && (
|
||||
<p className="text-[10px] text-destructive">{tokenError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Custom token editor ─────────────────────────────────────────── */}
|
||||
{activePresetId === 'custom' && draft && (
|
||||
<div className="border-t pt-5">
|
||||
|
||||
@ -45,7 +45,7 @@ export interface PaletteEntry {
|
||||
badgeRole: string; // tailwind classes for role badge
|
||||
}
|
||||
|
||||
export type ThemePresetId = 'default' | 'custom';
|
||||
export type ThemePresetId = 'default' | 'midnight' | 'forest' | 'sunset' | 'rose' | 'lavender' | 'arctic' | 'nord' | 'dracula' | 'light' | 'custom';
|
||||
|
||||
export interface ThemePreset {
|
||||
id: ThemePresetId;
|
||||
@ -64,6 +64,159 @@ export const THEME_PRESETS: ThemePreset[] = [
|
||||
description: 'Linear / Vercel inspired — neutral + single indigo accent',
|
||||
palette: null, // reads live from CSS vars
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
label: 'Midnight',
|
||||
description: 'Deep blue-black with electric blue accents',
|
||||
palette: {
|
||||
bg: '#0a0e1a', bgSubtle: '#111827', bgHover: '#1e293b', bgActive: '#334155',
|
||||
border: '#1e293b', borderFocus: '#3b82f6', borderMuted: '#1e293b',
|
||||
text: '#e2e8f0', textMuted: '#94a3b8', textSubtle: '#64748b',
|
||||
accent: '#3b82f6', accentHover: '#60a5fa', accentText: '#ffffff',
|
||||
icon: '#94a3b8', iconHover: '#e2e8f0',
|
||||
surface: '#111827', surface2: '#1e293b',
|
||||
online: '#22c55e', away: '#eab308', offline: '#475569',
|
||||
mentionBg: 'rgba(59,130,246,0.15)', mentionText: '#60a5fa',
|
||||
msgBg: '#111827', msgOwnBg: 'rgba(59,130,246,0.12)',
|
||||
panelBg: '#0f172a', badgeAi: 'bg-blue-500/15 text-blue-400 font-medium', badgeRole: 'bg-slate-700 text-slate-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
label: 'Forest',
|
||||
description: 'Warm dark with emerald green accents',
|
||||
palette: {
|
||||
bg: '#0c1210', bgSubtle: '#131f1a', bgHover: '#1a2e26', bgActive: '#243d33',
|
||||
border: '#1a2e26', borderFocus: '#10b981', borderMuted: '#1a2e26',
|
||||
text: '#d1fae5', textMuted: '#6ee7b7', textSubtle: '#34d399',
|
||||
accent: '#10b981', accentHover: '#34d399', accentText: '#021a0f',
|
||||
icon: '#6ee7b7', iconHover: '#a7f3d0',
|
||||
surface: '#131f1a', surface2: '#1a2e26',
|
||||
online: '#10b981', away: '#f59e0b', offline: '#4b5563',
|
||||
mentionBg: 'rgba(16,185,129,0.15)', mentionText: '#34d399',
|
||||
msgBg: '#131f1a', msgOwnBg: 'rgba(16,185,129,0.1)',
|
||||
panelBg: '#0a1410', badgeAi: 'bg-emerald-500/15 text-emerald-400 font-medium', badgeRole: 'bg-emerald-900 text-emerald-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
label: 'Sunset',
|
||||
description: 'Warm dark with orange-amber accents',
|
||||
palette: {
|
||||
bg: '#120e0a', bgSubtle: '#1c1612', bgHover: '#2a1f18', bgActive: '#3d2c20',
|
||||
border: '#2a1f18', borderFocus: '#f59e0b', borderMuted: '#2a1f18',
|
||||
text: '#fef3c7', textMuted: '#fbbf24', textSubtle: '#d97706',
|
||||
accent: '#f59e0b', accentHover: '#fbbf24', accentText: '#1c1207',
|
||||
icon: '#fbbf24', iconHover: '#fde68a',
|
||||
surface: '#1c1612', surface2: '#2a1f18',
|
||||
online: '#22c55e', away: '#f59e0b', offline: '#6b7280',
|
||||
mentionBg: 'rgba(245,158,11,0.15)', mentionText: '#fbbf24',
|
||||
msgBg: '#1c1612', msgOwnBg: 'rgba(245,158,11,0.1)',
|
||||
panelBg: '#0f0c09', badgeAi: 'bg-amber-500/15 text-amber-400 font-medium', badgeRole: 'bg-amber-900 text-amber-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rose',
|
||||
label: 'Rose',
|
||||
description: 'Soft dark with rose-pink accents',
|
||||
palette: {
|
||||
bg: '#130b11', bgSubtle: '#1e131a', bgHover: '#2d1c27', bgActive: '#3f2636',
|
||||
border: '#2d1c27', borderFocus: '#f43f5e', borderMuted: '#2d1c27',
|
||||
text: '#fce7f3', textMuted: '#f9a8d4', textSubtle: '#ec4899',
|
||||
accent: '#f43f5e', accentHover: '#fb7185', accentText: '#ffffff',
|
||||
icon: '#f9a8d4', iconHover: '#fbcfe8',
|
||||
surface: '#1e131a', surface2: '#2d1c27',
|
||||
online: '#22c55e', away: '#f59e0b', offline: '#6b7280',
|
||||
mentionBg: 'rgba(244,63,94,0.15)', mentionText: '#fb7185',
|
||||
msgBg: '#1e131a', msgOwnBg: 'rgba(244,63,94,0.1)',
|
||||
panelBg: '#100a0e', badgeAi: 'bg-rose-500/15 text-rose-400 font-medium', badgeRole: 'bg-rose-900 text-rose-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lavender',
|
||||
label: 'Lavender',
|
||||
description: 'Cool dark with purple-violet accents',
|
||||
palette: {
|
||||
bg: '#0e0b14', bgSubtle: '#16121e', bgHover: '#201a2e', bgActive: '#2e2540',
|
||||
border: '#201a2e', borderFocus: '#8b5cf6', borderMuted: '#201a2e',
|
||||
text: '#ede9fe', textMuted: '#c4b5fd', textSubtle: '#a78bfa',
|
||||
accent: '#8b5cf6', accentHover: '#a78bfa', accentText: '#ffffff',
|
||||
icon: '#c4b5fd', iconHover: '#ddd6fe',
|
||||
surface: '#16121e', surface2: '#201a2e',
|
||||
online: '#22c55e', away: '#f59e0b', offline: '#6b7280',
|
||||
mentionBg: 'rgba(139,92,246,0.15)', mentionText: '#a78bfa',
|
||||
msgBg: '#16121e', msgOwnBg: 'rgba(139,92,246,0.1)',
|
||||
panelBg: '#0b0810', badgeAi: 'bg-violet-500/15 text-violet-400 font-medium', badgeRole: 'bg-violet-900 text-violet-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'arctic',
|
||||
label: 'Arctic',
|
||||
description: 'Clean light with cyan-teal accents',
|
||||
palette: {
|
||||
bg: '#f0fdfa', bgSubtle: '#e0f7f0', bgHover: '#ccfbf1', bgActive: '#99f6e4',
|
||||
border: '#99f6e4', borderFocus: '#14b8a6', borderMuted: '#ccfbf1',
|
||||
text: '#134e4a', textMuted: '#0f766e', textSubtle: '#5eead4',
|
||||
accent: '#14b8a6', accentHover: '#2dd4bf', accentText: '#ffffff',
|
||||
icon: '#0f766e', iconHover: '#115e59',
|
||||
surface: '#e0f7f0', surface2: '#ccfbf1',
|
||||
online: '#10b981', away: '#f59e0b', offline: '#94a3b8',
|
||||
mentionBg: 'rgba(20,184,166,0.12)', mentionText: '#0f766e',
|
||||
msgBg: '#e0f7f0', msgOwnBg: 'rgba(20,184,166,0.08)',
|
||||
panelBg: '#f0fdfa', badgeAi: 'bg-teal-500/15 text-teal-700 font-medium', badgeRole: 'bg-teal-100 text-teal-800 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nord',
|
||||
label: 'Nord',
|
||||
description: 'Arctic blue-gray inspired by Nord theme',
|
||||
palette: {
|
||||
bg: '#2e3440', bgSubtle: '#3b4252', bgHover: '#434c5e', bgActive: '#4c566a',
|
||||
border: '#434c5e', borderFocus: '#88c0d0', borderMuted: '#3b4252',
|
||||
text: '#eceff4', textMuted: '#d8dee9', textSubtle: '#81a1c1',
|
||||
accent: '#88c0d0', accentHover: '#8fbcbb', accentText: '#2e3440',
|
||||
icon: '#d8dee9', iconHover: '#eceff4',
|
||||
surface: '#3b4252', surface2: '#434c5e',
|
||||
online: '#a3be8c', away: '#ebcb8b', offline: '#4c566a',
|
||||
mentionBg: 'rgba(136,192,208,0.15)', mentionText: '#88c0d0',
|
||||
msgBg: '#3b4252', msgOwnBg: 'rgba(136,192,208,0.1)',
|
||||
panelBg: '#2e3440', badgeAi: 'bg-sky-500/15 text-sky-300 font-medium', badgeRole: 'bg-slate-700 text-slate-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dracula',
|
||||
label: 'Dracula',
|
||||
description: 'Classic Dracula theme with pink and purple',
|
||||
palette: {
|
||||
bg: '#282a36', bgSubtle: '#343746', bgHover: '#44475a', bgActive: '#565970',
|
||||
border: '#44475a', borderFocus: '#bd93f9', borderMuted: '#343746',
|
||||
text: '#f8f8f2', textMuted: '#6272a4', textSubtle: '#6272a4',
|
||||
accent: '#bd93f9', accentHover: '#caa9fa', accentText: '#282a36',
|
||||
icon: '#f8f8f2', iconHover: '#f1fa8c',
|
||||
surface: '#343746', surface2: '#44475a',
|
||||
online: '#50fa7b', away: '#f1fa8c', offline: '#6272a4',
|
||||
mentionBg: 'rgba(189,147,249,0.15)', mentionText: '#bd93f9',
|
||||
msgBg: '#343746', msgOwnBg: 'rgba(189,147,249,0.1)',
|
||||
panelBg: '#282a36', badgeAi: 'bg-purple-500/15 text-purple-400 font-medium', badgeRole: 'bg-purple-900 text-purple-300 font-medium',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'light',
|
||||
label: 'Clean Light',
|
||||
description: 'Minimal light theme with blue accents',
|
||||
palette: {
|
||||
bg: '#ffffff', bgSubtle: '#f8fafc', bgHover: '#f1f5f9', bgActive: '#e2e8f0',
|
||||
border: '#e2e8f0', borderFocus: '#3b82f6', borderMuted: '#f1f5f9',
|
||||
text: '#0f172a', textMuted: '#64748b', textSubtle: '#94a3b8',
|
||||
accent: '#3b82f6', accentHover: '#2563eb', accentText: '#ffffff',
|
||||
icon: '#64748b', iconHover: '#0f172a',
|
||||
surface: '#f8fafc', surface2: '#f1f5f9',
|
||||
online: '#22c55e', away: '#f59e0b', offline: '#cbd5e1',
|
||||
mentionBg: 'rgba(59,130,246,0.08)', mentionText: '#2563eb',
|
||||
msgBg: '#f8fafc', msgOwnBg: 'rgba(59,130,246,0.06)',
|
||||
panelBg: '#ffffff', badgeAi: 'bg-blue-500/10 text-blue-600 font-medium', badgeRole: 'bg-slate-100 text-slate-700 font-medium',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Well-known CSS vars that map to PaletteEntry keys */
|
||||
@ -237,3 +390,81 @@ export function deactivateCustomPalette() {
|
||||
clearCustomPalette();
|
||||
resetDOMFromPalette();
|
||||
}
|
||||
|
||||
// ─── Theme Token ─────────────────────────────────────────────────────────────
|
||||
// Encode a palette into a compact shareable token, and decode it back.
|
||||
//
|
||||
// Token format: "T1:" + base64url(minified JSON)
|
||||
// Version byte (T1) allows future format changes.
|
||||
// Only the 16 core color fields are encoded (badge classes are derived).
|
||||
|
||||
const TOKEN_VERSION = 'T1:';
|
||||
const TOKEN_FIELDS: (keyof PaletteEntry)[] = [
|
||||
'bg', 'bgSubtle', 'bgHover', 'bgActive',
|
||||
'border', 'borderFocus', 'borderMuted',
|
||||
'text', 'textMuted', 'textSubtle',
|
||||
'accent', 'accentHover', 'accentText',
|
||||
'icon', 'iconHover',
|
||||
'surface', 'surface2',
|
||||
'online', 'away', 'offline',
|
||||
'mentionBg', 'mentionText',
|
||||
'msgBg', 'msgOwnBg',
|
||||
'panelBg',
|
||||
];
|
||||
|
||||
function base64urlEncode(str: string): string {
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
let s = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
return atob(s);
|
||||
}
|
||||
|
||||
/** Encode a PaletteEntry into a shareable token string. */
|
||||
export function encodeThemeToken(palette: PaletteEntry): string {
|
||||
const compact: Record<string, string> = {};
|
||||
for (const key of TOKEN_FIELDS) {
|
||||
compact[key] = (palette as unknown as Record<string, string>)[key];
|
||||
}
|
||||
const json = JSON.stringify(compact);
|
||||
return TOKEN_VERSION + base64urlEncode(json);
|
||||
}
|
||||
|
||||
/** Decode a token string back into a PaletteEntry. Returns null if invalid. */
|
||||
export function decodeThemeToken(token: string): PaletteEntry | null {
|
||||
try {
|
||||
if (!token.startsWith(TOKEN_VERSION)) return null;
|
||||
const b64 = token.slice(TOKEN_VERSION.length);
|
||||
const json = base64urlDecode(b64);
|
||||
const compact = JSON.parse(json) as Record<string, string>;
|
||||
|
||||
// Validate that all required fields are present
|
||||
for (const key of TOKEN_FIELDS) {
|
||||
if (typeof compact[key] !== 'string') return null;
|
||||
}
|
||||
|
||||
// Build full palette with derived badge classes
|
||||
return {
|
||||
...compact,
|
||||
badgeAi: 'bg-accent/10 text-accent font-medium',
|
||||
badgeRole: 'bg-muted text-muted-foreground font-medium',
|
||||
} as PaletteEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a string looks like a valid theme token. */
|
||||
export function isThemeToken(str: string): boolean {
|
||||
return str.startsWith(TOKEN_VERSION) && str.length > TOKEN_VERSION.length;
|
||||
}
|
||||
|
||||
/** Apply a theme from a token string. Returns true if successful. */
|
||||
export function applyThemeFromToken(token: string): boolean {
|
||||
const palette = decodeThemeToken(token);
|
||||
if (!palette) return false;
|
||||
activateCustomPalette(palette);
|
||||
return true;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user