diff --git a/src/components/room/ThemeSwitcher.tsx b/src/components/room/ThemeSwitcher.tsx index 476afad..0ecf61e 100644 --- a/src/components/room/ThemeSwitcher.tsx +++ b/src/components/room/ThemeSwitcher.tsx @@ -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(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 ( @@ -371,6 +425,63 @@ export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) { + {/* ── 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 && (
diff --git a/src/components/room/design-system.ts b/src/components/room/design-system.ts index f478324..a13e77c 100644 --- a/src/components/room/design-system.ts +++ b/src/components/room/design-system.ts @@ -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 = {}; + for (const key of TOKEN_FIELDS) { + compact[key] = (palette as unknown as Record)[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; + + // 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; +}