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:
ZhenYi 2026-04-29 09:50:13 +08:00
parent 907b5ee3bf
commit 867f216a1f
2 changed files with 344 additions and 2 deletions

View File

@ -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">

View File

@ -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;
}