From c0396be497ae3cfe7a04ee9319107a27b5ec47b3 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Tue, 17 Feb 2026 14:14:50 -0500 Subject: [PATCH] feat: add theme preset gallery and custom color editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 10 built-in theme presets: Goose Classic, Nord, Dracula, Solarized, Monokai, GitHub, Gruvbox, Tokyo Night, One Dark, High Contrast - Add PresetGallery with tag filtering and one-click apply - Add ThemeColorEditor with Presets and Custom Colors tabs - Add SimpleColorPicker (hue spectrum + saturation/lightness grid) - Add ColorPreview showing live preview across real UI components - Add custom theme CRUD (save/edit/delete to localStorage) - Add "Customize Colors" button in Settings → Appearance - Presets define partial color overrides (~18 keys), merged on top of goose-classic defaults (~60+ keys) at apply time - Zero server endpoints — everything is client-side - No external dependencies for color picking --- .../settings/app/AppSettingsSection.tsx | 16 +- .../ColorPicker/SimpleColorPicker.tsx | 243 +++++++++ .../ThemeColorEditor/Preview/ColorPreview.tsx | 483 ++++++++++++++++++ .../ThemeSelector/PresetGallery.tsx | 233 +++++++++ .../settings/app/ThemeColorEditor/index.tsx | 413 +++++++++++++++ .../settings/app/ThemeColorEditor/types.ts | 139 +++++ ui/desktop/src/contexts/ThemeContext.tsx | 106 +++- ui/desktop/src/themes/presets/dracula.ts | 63 +++ ui/desktop/src/themes/presets/github.ts | 63 +++ .../src/themes/presets/goose-classic.ts | 63 +++ ui/desktop/src/themes/presets/gruvbox.ts | 63 +++ .../src/themes/presets/high-contrast.ts | 63 +++ ui/desktop/src/themes/presets/index.ts | 143 ++++++ ui/desktop/src/themes/presets/monokai.ts | 63 +++ ui/desktop/src/themes/presets/nord.ts | 63 +++ ui/desktop/src/themes/presets/one-dark.ts | 63 +++ ui/desktop/src/themes/presets/solarized.ts | 63 +++ ui/desktop/src/themes/presets/tokyo-night.ts | 63 +++ ui/desktop/src/themes/presets/types.ts | 31 ++ 19 files changed, 2426 insertions(+), 11 deletions(-) create mode 100644 ui/desktop/src/components/settings/app/ThemeColorEditor/ColorPicker/SimpleColorPicker.tsx create mode 100644 ui/desktop/src/components/settings/app/ThemeColorEditor/Preview/ColorPreview.tsx create mode 100644 ui/desktop/src/components/settings/app/ThemeColorEditor/ThemeSelector/PresetGallery.tsx create mode 100644 ui/desktop/src/components/settings/app/ThemeColorEditor/index.tsx create mode 100644 ui/desktop/src/components/settings/app/ThemeColorEditor/types.ts create mode 100644 ui/desktop/src/themes/presets/dracula.ts create mode 100644 ui/desktop/src/themes/presets/github.ts create mode 100644 ui/desktop/src/themes/presets/goose-classic.ts create mode 100644 ui/desktop/src/themes/presets/gruvbox.ts create mode 100644 ui/desktop/src/themes/presets/high-contrast.ts create mode 100644 ui/desktop/src/themes/presets/index.ts create mode 100644 ui/desktop/src/themes/presets/monokai.ts create mode 100644 ui/desktop/src/themes/presets/nord.ts create mode 100644 ui/desktop/src/themes/presets/one-dark.ts create mode 100644 ui/desktop/src/themes/presets/solarized.ts create mode 100644 ui/desktop/src/themes/presets/tokyo-night.ts create mode 100644 ui/desktop/src/themes/presets/types.ts diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 30243fa06de2..064d73167332 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -13,6 +13,7 @@ import BlockLogoBlack from './icons/block-lockup_black.png'; import BlockLogoWhite from './icons/block-lockup_white.png'; import TelemetrySettings from './TelemetrySettings'; import { trackSettingToggled } from '../../../utils/analytics'; +import { ThemeColorEditor } from './ThemeColorEditor'; interface AppSettingsSectionProps { scrollToSection?: string; @@ -27,6 +28,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [showNotificationModal, setShowNotificationModal] = useState(false); const [showPricing, setShowPricing] = useState(true); const [isDarkMode, setIsDarkMode] = useState(false); + const [showColorEditor, setShowColorEditor] = useState(false); const updateSectionRef = useRef(null); // Check if GOOSE_VERSION is set to determine if Updates section should be shown @@ -265,7 +267,16 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti Customize the look and feel of goose - +
+ + +
@@ -394,6 +405,9 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + {showColorEditor && ( + setShowColorEditor(false)} /> + )} ); } diff --git a/ui/desktop/src/components/settings/app/ThemeColorEditor/ColorPicker/SimpleColorPicker.tsx b/ui/desktop/src/components/settings/app/ThemeColorEditor/ColorPicker/SimpleColorPicker.tsx new file mode 100644 index 000000000000..fe3d59eed822 --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/ColorPicker/SimpleColorPicker.tsx @@ -0,0 +1,243 @@ +/** + * SimpleColorPicker Component + * + * A simplified color picker with discrete color stops instead of gradients. + * Uses a spectrum bar of color squares and a saturation/lightness grid. + */ + +import { useState } from 'react'; + +interface SimpleColorPickerProps { + color: string; + onChange: (color: string) => void; +} + +// Hue spectrum colors (24 stops around the color wheel for maximum precision) +const HUE_COLORS = [ + '#ff0000', // Red (0°) + '#ff4000', // Red-Orange + '#ff8000', // Orange (30°) + '#ffbf00', // Orange-Yellow + '#ffff00', // Yellow (60°) + '#bfff00', // Yellow-Lime + '#80ff00', // Lime (90°) + '#40ff00', // Lime-Green + '#00ff00', // Green (120°) + '#00ff40', // Green-Spring + '#00ff80', // Spring (150°) + '#00ffbf', // Spring-Cyan + '#00ffff', // Cyan (180°) + '#00bfff', // Cyan-Azure + '#0080ff', // Azure (210°) + '#0040ff', // Azure-Blue + '#0000ff', // Blue (240°) + '#4000ff', // Blue-Violet + '#8000ff', // Violet (270°) + '#bf00ff', // Violet-Purple + '#ff00ff', // Purple (300°) + '#ff00bf', // Purple-Magenta + '#ff0080', // Magenta (330°) + '#ff0040', // Magenta-Red +]; + +// Generate saturation/lightness grid for a given hue +function generateSaturationGrid(hueColor: string): string[][] { + const grid: string[][] = []; + const rows = 10; // Lightness levels (maximum granularity) + const cols = 15; // Saturation levels (maximum granularity) + + // Parse hue color to HSL + const hsl = hexToHSL(hueColor); + + for (let row = 0; row < rows; row++) { + const rowColors: string[] = []; + const lightness = 95 - (row * 9.5); // 95% to 5% in 9.5% steps + + for (let col = 0; col < cols; col++) { + const saturation = col * 7; // 0% to 98% in 7% steps + const color = hslToHex(hsl.h, saturation, lightness); + rowColors.push(color); + } + grid.push(rowColors); + } + + return grid; +} + +// Helper: Convert hex to HSL +function hexToHSL(hex: string): { h: number; s: number; l: number } { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + case b: + h = ((r - g) / d + 4) / 6; + break; + } + } + + return { h: h * 360, s: s * 100, l: l * 100 }; +} + +// Helper: Convert HSL to hex +function hslToHex(h: number, s: number, l: number): string { + h = h / 360; + s = s / 100; + l = l / 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + const toHex = (x: number) => { + const hex = Math.round(x * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +// Helper: Find closest hue from HUE_COLORS for a given color +function findClosestHue(color: string): string { + const hsl = hexToHSL(color); + const targetHue = hsl.h; + + // Find the closest hue from our predefined colors + let closestHue = HUE_COLORS[0]; + let minDiff = 360; + + HUE_COLORS.forEach(hueColor => { + const hueHSL = hexToHSL(hueColor); + let diff = Math.abs(hueHSL.h - targetHue); + // Handle wrap-around (e.g., 350° is close to 10°) + if (diff > 180) diff = 360 - diff; + + if (diff < minDiff) { + minDiff = diff; + closestHue = hueColor; + } + }); + + return closestHue; +} + +export function SimpleColorPicker({ color, onChange }: SimpleColorPickerProps) { + // Initialize with the closest hue to the current color + const [selectedHue, setSelectedHue] = useState(() => findClosestHue(color)); + const saturationGrid = generateSaturationGrid(selectedHue); + + const handleHueSelect = (hueColor: string) => { + setSelectedHue(hueColor); + }; + + const handleColorSelect = (selectedColor: string) => { + onChange(selectedColor); + }; + + return ( +
+ {/* Hue Spectrum Bar */} +
+
Select Hue
+
+ {HUE_COLORS.map((hueColor) => ( +
+
+ + {/* Saturation/Lightness Grid */} +
+
Select Shade
+
+ {saturationGrid.map((row, rowIndex) => ( +
+ {row.map((gridColor, colIndex) => ( +
+ ))} +
+
+ + {/* Grayscale Row */} +
+
Grayscale
+
+ {[ + '#000000', '#111111', '#222222', '#333333', '#444444', + '#555555', '#666666', '#777777', '#888888', '#999999', + '#aaaaaa', '#bbbbbb', '#cccccc', '#dddddd', '#eeeeee', '#ffffff', + ].map((grayColor) => ( +
+
+
+ ); +} diff --git a/ui/desktop/src/components/settings/app/ThemeColorEditor/Preview/ColorPreview.tsx b/ui/desktop/src/components/settings/app/ThemeColorEditor/Preview/ColorPreview.tsx new file mode 100644 index 000000000000..01cca69c726d --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/Preview/ColorPreview.tsx @@ -0,0 +1,483 @@ +/** + * ColorPreview Component + * + * Shows 1:1 accurate previews of how a selected color variable is used in the actual Goose UI. + * Uses real component structures and class names from the app. + */ + +import { ColorVariable } from '../types'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../../../ui/card'; +import { Button } from '../../../../ui/button'; +import { Home, MessageSquarePlus, FileText, AppWindow, Clock, Puzzle, Save, AlertTriangle, RotateCcw } from 'lucide-react'; +import { Gear } from '../../../../icons'; + +interface ColorPreviewProps { + variable: ColorVariable; + lightColor: string; + darkColor: string; + currentMode: 'light' | 'dark'; + allColors: Record; // All current theme colors for the active mode +} + +export function ColorPreview({ variable, lightColor, darkColor, currentMode, allColors }: ColorPreviewProps) { + const currentColor = currentMode === 'light' ? lightColor : darkColor; + + // Create inline styles for all theme colors to override CSS variables + const themeStyles = Object.entries(allColors).reduce>((acc, [key, value]) => { + acc[`--${key}`] = value; + return acc; + }, {}); + + // Render different previews based on color category + const renderPreview = () => { + switch (variable.category) { + case 'background': + return ; + case 'text': + return ; + case 'border': + return ; + case 'ring': + return ; + default: + return null; + } + }; + + return ( +
+ {/* Color Info Header - Top Aligned */} +
+
+
+
+

+ {variable.label} +

+

+ {variable.description} +

+
+ {currentColor} +
+
+
+
+
+ + {/* Usage Examples - Centered Vertically with Live Theme Colors */} +
+
+ {renderPreview()} +
+
+
+ ); +} + +// Background color previews - Using REAL Goose UI components +function BackgroundPreview({ variable, color }: { variable: ColorVariable; color: string }) { + const varName = variable.name; + + if (varName === 'color-background-primary') { + return ( + <> + {/* Main app background preview */} +
+
+

+ This is the main background color for the entire app. +

+

+ Used in: Chat area, main content, message list +

+
+
+ + {/* Goose AI Message */} +
+
+
+
+

+ I'll help you with that! Let me check the files in your project. +

+
+
+
+ 2:45 PM +
+
+
+
+
+ + ); + } + + if (varName === 'color-background-secondary') { + return ( +
+
+
+ + Home +
+
+ + Chat +
+
+
+ + Recipes +
+
+ + Apps +
+
+ + Scheduler +
+
+ + Extensions +
+
+
+ + Settings +
+
+
+ ); + } + + if (varName === 'color-background-tertiary') { + return ( +
+

Hover over items:

+
+
+ Hovered item (tertiary background) +
+
+ Normal item +
+
+
+ ); + } + + if (varName === 'color-background-inverse') { + return ( + <> + {/* Primary Action Button with Icon (Exact Replica) */} + + + {/* User Chat Bubble (Exact Replica from UserMessage.tsx) */} +
+
+
+
+
+

+ Can you help me customize the theme colors? +

+
+
+
+
+
+ + ); + } + + if (varName === 'color-background-danger') { + return ( + + ); + } + + if (varName === 'color-background-info') { + return ( + + +
+ +
+ Prompt Editing +

+ Customize the prompts that define goose's behavior in different contexts. These + prompts use Jinja2 templating syntax. +

+
+ +
+
+
+ ); + } + + return ( +
+

Background color preview

+
+ ); +} + +// Text color previews - Using REAL Goose UI patterns +function TextPreview({ variable, color }: { variable: ColorVariable; color: string }) { + const varName = variable.name; + + if (varName === 'color-text-primary') { + return ( + <> +
+
+
+
+

+ I'll help you with that! Let me check the files in your project and make the necessary changes. +

+
+
+
+
+ + + + Settings Section + Primary text appears in headings and main content + + + + ); + } + + if (varName === 'color-text-secondary') { + return ( + + + Menu bar icon + + Show goose in the menu bar + + + +
+
+

Setting Name

+

+ This is secondary text used for descriptions and labels +

+
+
+
+
+ ); + } + + if (varName === 'color-text-inverse') { + return ( + <> + +
+

+ Text on dark/inverse backgrounds +

+
+ + ); + } + + if (varName === 'color-text-danger') { + return ( + <> +
+

⚠️ Error

+

+ Failed to load configuration file +

+
+ + + ); + } + + if (varName === 'color-text-success') { + return ( +
+

✓ Success

+

+ Theme saved successfully! +

+
+ ); + } + + if (varName === 'color-text-warning') { + return ( +
+

⚡ Warning

+

+ This action cannot be undone +

+
+ ); + } + + if (varName === 'color-text-info') { + return ( + <> +
+

ℹ️ Information

+

+ Info text is used for helpful tips, notifications, and informational messages +

+
+ +
+
+
+

Pro Tip

+

+ You can use keyboard shortcuts to navigate faster through the app +

+
+
+ + ); + } + + return ( +

Sample text in this color

+ ); +} + +// Border color previews - Using REAL Goose UI patterns +function BorderPreview({ variable, color }: { variable: ColorVariable; color: string }) { + const varName = variable.name; + + if (varName === 'color-border-primary') { + return ( + <> + + + Card Title + This card uses the primary border color + + +

Card content goes here

+
+
+ + + +
+

Section Above

+
+

Section Below

+
+ + ); + } + + if (varName === 'color-border-secondary') { + return ( +
+

Hovered card border

+
+ ); + } + + if (varName === 'color-border-danger') { + return ( + <> +
+

Error State

+
+ + + ); + } + + if (varName === 'color-border-info') { + return ( +
+

Info State

+
+ ); + } + + return ( +
+

Element with this border color

+
+ ); +} + +// Ring (focus) color previews - Using REAL button focus styles +function RingPreview({ color }: { variable: ColorVariable; color: string }) { + return ( + <> + + + + +

+ The ring color appears when elements receive keyboard focus (Tab key navigation). + This is essential for accessibility and keyboard navigation. +

+ + ); +} + + diff --git a/ui/desktop/src/components/settings/app/ThemeColorEditor/ThemeSelector/PresetGallery.tsx b/ui/desktop/src/components/settings/app/ThemeColorEditor/ThemeSelector/PresetGallery.tsx new file mode 100644 index 000000000000..260a6d3eefa4 --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/ThemeSelector/PresetGallery.tsx @@ -0,0 +1,233 @@ +/** + * Theme Preset Gallery + * + * Browse and apply built-in + custom theme presets. + * Fully client-side — reads from the TS preset registry and localStorage. + */ + +import { useState, useMemo } from 'react'; +import { Button } from '../../../../ui/button'; +import { toast } from 'react-toastify'; +import type { ThemePreset } from '../../../../../themes/presets/types'; +import { + getAllPresets, + deleteCustomTheme, +} from '../../../../../themes/presets'; +import { useTheme } from '../../../../../contexts/ThemeContext'; +import { Check, Download, Trash2, Sliders } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../../ui/Tooltip'; + +interface PresetGalleryProps { + onApply?: () => void; + onEdit?: (preset: ThemePreset) => void; +} + +export function PresetGallery({ onApply, onEdit }: PresetGalleryProps) { + const { resolvedTheme, applyPreset, activePresetId } = useTheme(); + const [applying, setApplying] = useState(null); + const [selectedTag, setSelectedTag] = useState(null); + const [presets, setPresets] = useState(() => getAllPresets()); + const allTags = useMemo(() => { + const tags = new Set(); + presets.forEach((p) => p.tags.forEach((t) => tags.add(t))); + return Array.from(tags).sort(); + }, [presets]); + + const filteredPresets = useMemo(() => { + if (!selectedTag) return presets; + return presets.filter((p) => p.tags.includes(selectedTag)); + }, [presets, selectedTag]); + + const handleApplyPreset = (presetId: string) => { + setApplying(presetId); + applyPreset(presetId); + toast.success('Theme applied!'); + setApplying(null); + onApply?.(); + }; + + const handleDeleteTheme = (themeId: string, themeName: string) => { + if (!window.confirm(`Are you sure you want to delete "${themeName}"? This cannot be undone.`)) { + return; + } + + deleteCustomTheme(themeId); + + // If the deleted theme was active, reset to default + if (activePresetId === themeId) { + applyPreset(null); + } + + toast.success(`Theme "${themeName}" deleted`); + setPresets(getAllPresets()); + }; + + return ( +
+ {/* Filter Tags */} +
+
+ + {allTags.map((tag) => ( + + ))} +
+
+ + {/* Theme Grid */} +
+ {filteredPresets.map((preset) => { + const isApplied = preset.id === activePresetId; + const isCustom = preset.isCustom || preset.tags.includes('custom'); + + return ( +
+ {/* Theme Preview Colors */} +
+
+
+
+
+
+ +
+ + {/* Bottom-aligned content */} +
+
+

{preset.name}

+

{preset.description}

+

by {preset.author}

+
+ +
+ {preset.tags.map((tag) => ( + + {tag} + + ))} +
+ +
+ + + + + + {applying === preset.id + ? 'Applying...' + : isApplied + ? 'Currently Applied' + : 'Apply Theme'} + + + + {isCustom && ( + <> + + + + + Edit Theme + + + + + + + Delete Theme + + + )} +
+
+
+ ); + })} +
+ + {filteredPresets.length === 0 && ( +
+ No themes found matching your filter. +
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/settings/app/ThemeColorEditor/index.tsx b/ui/desktop/src/components/settings/app/ThemeColorEditor/index.tsx new file mode 100644 index 000000000000..03b250b9be07 --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/index.tsx @@ -0,0 +1,413 @@ +/** + * ThemeColorEditor Component + * + * Theme customization with preset gallery and custom color picking. + * Fully client-side — no server round-trips. Presets and custom themes + * are stored in localStorage and applied via style.setProperty(). + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '../../../ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../../ui/tabs'; +import { Button } from '../../../ui/button'; +import { + ThemeColorEditorProps, + ThemeColors, + ColorMode, + COLOR_VARIABLES, +} from './types'; +import { toast } from 'react-toastify'; +import { SimpleColorPicker } from './ColorPicker/SimpleColorPicker'; +import { PresetGallery } from './ThemeSelector/PresetGallery'; +import { ColorPreview } from './Preview/ColorPreview'; +import { RotateCcw, Save, Palette, Paintbrush, Pipette } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../../../ui/Tooltip'; +import { useTheme } from '../../../../contexts/ThemeContext'; +import { lightTokens, darkTokens } from '../../../../theme/theme-tokens'; +import { + saveCustomTheme as persistCustomTheme, + getThemePreset, +} from '../../../../themes/presets'; +import type { ThemePreset } from '../../../../themes/presets/types'; + +/** Strip the `--` prefix so keys match the preset format (`color-background-primary`). */ +function stripPrefix(key: string): string { + return key.startsWith('--') ? key.slice(2) : key; +} + +/** Build a ThemeColors object from the current token defaults, optionally overlaying a preset. */ +function buildColorsFromTokens(preset?: ThemePreset): ThemeColors { + const light: Record = {}; + const dark: Record = {}; + + for (const [key, value] of Object.entries(lightTokens)) { + if (key.startsWith('--color-')) { + light[stripPrefix(key)] = value; + } + } + for (const [key, value] of Object.entries(darkTokens)) { + if (key.startsWith('--color-')) { + dark[stripPrefix(key)] = value; + } + } + + if (preset) { + Object.assign(light, preset.colors.light); + Object.assign(dark, preset.colors.dark); + } + + return { light, dark }; +} + +export function ThemeColorEditor({ onClose }: ThemeColorEditorProps) { + const { resolvedTheme, applyPreset, activePresetId } = useTheme(); + const [saving, setSaving] = useState(false); + const [themeColors, setThemeColors] = useState(() => { + const preset = activePresetId ? getThemePreset(activePresetId) : undefined; + return buildColorsFromTokens(preset); + }); + const [activeTab, setActiveTab] = useState<'presets' | 'customize'>('presets'); + const [selectedVariable, setSelectedVariable] = useState(null); + const [themeName, setThemeName] = useState('My Custom Theme'); + const [themeDescription, setThemeDescription] = useState(''); + const [editingThemeId, setEditingThemeId] = useState(null); + + const activeMode: ColorMode = resolvedTheme; + + // Sync colors when active preset changes externally + useEffect(() => { + const preset = activePresetId ? getThemePreset(activePresetId) : undefined; + setThemeColors(buildColorsFromTokens(preset)); + }, [activePresetId]); + + const handleColorChange = (variableName: string, color: string) => { + setThemeColors((prev) => ({ + ...prev, + [activeMode]: { + ...prev[activeMode], + [variableName]: color, + }, + })); + }; + + const handleSave = () => { + if (!themeName.trim()) { + toast.error('Please enter a theme name'); + return; + } + + setSaving(true); + + const themeId = + editingThemeId || + `custom-${themeName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`; + + // Persist to localStorage + persistCustomTheme({ + id: themeId, + name: themeName.trim(), + author: 'You', + description: themeDescription.trim() || 'Custom theme', + tags: ['custom'], + colors: { + light: themeColors.light, + dark: themeColors.dark, + }, + version: '1.0.0', + isCustom: true, + }); + + // Apply immediately (no reload needed) + applyPreset(themeId); + + const action = editingThemeId ? 'updated' : 'saved'; + toast.success(`Theme "${themeName}" ${action} and applied!`); + setSaving(false); + }; + + const handleReset = () => { + if ( + !window.confirm( + 'Are you sure you want to reset to the default theme? This will remove all customizations.' + ) + ) { + return; + } + + // Clear active preset and any overrides + localStorage.removeItem('theme-overrides'); + applyPreset(null); + setThemeColors(buildColorsFromTokens()); + setEditingThemeId(null); + toast.success('Theme reset to Goose Classic!'); + }; + + const handleEditTheme = (preset: ThemePreset) => { + // Merge preset colors on top of defaults so every variable has a value + setThemeColors(buildColorsFromTokens(preset)); + setThemeName(preset.name); + setThemeDescription(preset.description || ''); + setEditingThemeId(preset.id); + setActiveTab('customize'); + toast.info(`Editing "${preset.name}"`); + }; + + const groupedVariables = COLOR_VARIABLES.reduce( + (acc, variable) => { + if (!acc[variable.category]) { + acc[variable.category] = []; + } + acc[variable.category].push(variable); + return acc; + }, + {} as Record + ); + + return ( + + + +
+
+ Theme Builder + + Create your perfect theme with presets or custom colors + +
+
+ + + + + Reset to Default Theme + + + + + + + {saving ? 'Saving...' : 'Save Theme'} + +
+
+ +
+ setActiveTab(v as 'presets' | 'customize')} + > + + + + Theme Presets + + + + Custom Colors + + + + + {activeTab === 'customize' && ( +
+ Editing:{' '} + {activeMode} Mode +
+ )} +
+
+ + setActiveTab(v as 'presets' | 'customize')} + className="flex-1 flex flex-col overflow-hidden" + > +
+ + Theme Presets + Custom Colors + +
+ + {/* Presets Tab */} + + + + + {/* Customize Tab */} + + {/* Split Panel Layout */} +
+ {/* Left Panel: Color Pickers (40%) */} +
+ {/* Custom Theme Info Card */} +
+
+
+
+
+
+
+ +
+ + setThemeName(e.target.value)} + className="w-full px-3 py-2 text-sm border border-border-primary rounded bg-background-primary text-text-primary font-medium" + placeholder="My Custom Theme" + /> +
+ +
+ + setThemeDescription(e.target.value)} + className="w-full px-3 py-2 text-sm border border-border-primary rounded bg-background-primary text-text-secondary" + placeholder="Describe your theme..." + /> +
+
+ + {Object.entries(groupedVariables).map(([category, variables]) => ( +
+

+ {category} Colors +

+
+ {variables.map((variable) => { + const currentColor = + themeColors[activeMode][variable.name] || '#000000'; + const isSelected = selectedVariable === variable.name; + + return ( +
setSelectedVariable(variable.name)} + > +
+
+ + {variable.description && ( +

+ {variable.description} +

+ )} +
+
+
+ + {isSelected && ( +
+ + handleColorChange(variable.name, color) + } + /> + + handleColorChange(variable.name, e.target.value) + } + className="w-full px-3 py-2 text-sm border border-border-primary rounded bg-background-primary text-text-primary font-mono" + placeholder="#000000" + /> +
+ )} +
+ ); + })} +
+
+ ))} +
+ + {/* Right Panel: Live Preview (60%) */} +
+ {selectedVariable ? ( + v.name === selectedVariable)!} + lightColor={themeColors.light[selectedVariable] || '#000000'} + darkColor={themeColors.dark[selectedVariable] || '#000000'} + currentMode={activeMode} + allColors={themeColors[activeMode]} + /> + ) : ( +
+
+ +
+

+ Select a color to preview +

+

+ Click any color on the left to see where it's used in the UI +

+
+
+
+ )} +
+
+ + + +
+ ); +} diff --git a/ui/desktop/src/components/settings/app/ThemeColorEditor/types.ts b/ui/desktop/src/components/settings/app/ThemeColorEditor/types.ts new file mode 100644 index 000000000000..1db0b9df6c40 --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/types.ts @@ -0,0 +1,139 @@ +/** + * ThemeColorEditor Types + */ + +export interface ThemeColors { + light: Record; + dark: Record; +} + +export interface ThemeColorEditorProps { + onClose: () => void; +} + +export type ColorMode = 'light' | 'dark'; + +export interface ColorVariable { + name: string; + label: string; + category: 'background' | 'border' | 'text' | 'ring'; + description?: string; +} + +export const COLOR_VARIABLES: ColorVariable[] = [ + // Background colors + { + name: 'color-background-primary', + label: 'Primary Background', + category: 'background', + description: 'Chat area, main content, message list', + }, + { + name: 'color-background-secondary', + label: 'Secondary Background', + category: 'background', + description: 'Sidebar, cards, settings panels', + }, + { + name: 'color-background-tertiary', + label: 'Tertiary Background', + category: 'background', + description: 'Hover states, nested panels, active items', + }, + { + name: 'color-background-inverse', + label: 'Inverse Background', + category: 'background', + description: 'Primary buttons, selected states', + }, + { + name: 'color-background-danger', + label: 'Danger Background', + category: 'background', + description: 'Error messages, delete buttons', + }, + { + name: 'color-background-info', + label: 'Info Background', + category: 'background', + description: 'Info messages, tips, help text', + }, + + // Border colors + { + name: 'color-border-primary', + label: 'Primary Border', + category: 'border', + description: 'Cards, inputs, dividers, separators', + }, + { + name: 'color-border-secondary', + label: 'Secondary Border', + category: 'border', + description: 'Hover states, focus states', + }, + { + name: 'color-border-danger', + label: 'Danger Border', + category: 'border', + description: 'Error inputs, warning boxes', + }, + { + name: 'color-border-info', + label: 'Info Border', + category: 'border', + description: 'Info boxes, help panels', + }, + + // Text colors + { + name: 'color-text-primary', + label: 'Primary Text', + category: 'text', + description: 'Headings, body text, chat messages', + }, + { + name: 'color-text-secondary', + label: 'Secondary Text', + category: 'text', + description: 'Labels, captions, timestamps, metadata', + }, + { + name: 'color-text-inverse', + label: 'Inverse Text', + category: 'text', + description: 'Text on buttons, selected items', + }, + { + name: 'color-text-danger', + label: 'Danger Text', + category: 'text', + description: 'Error messages, delete actions', + }, + { + name: 'color-text-success', + label: 'Success Text', + category: 'text', + description: 'Success messages, confirmations', + }, + { + name: 'color-text-warning', + label: 'Warning Text', + category: 'text', + description: 'Warning messages, caution text', + }, + { + name: 'color-text-info', + label: 'Info Text', + category: 'text', + description: 'Info messages, tips, help text', + }, + + // Ring colors + { + name: 'color-ring-primary', + label: 'Primary Ring', + category: 'ring', + description: 'Focus rings on buttons, inputs (accessibility)', + }, +]; diff --git a/ui/desktop/src/contexts/ThemeContext.tsx b/ui/desktop/src/contexts/ThemeContext.tsx index 31daf3265bef..a280326e1a78 100644 --- a/ui/desktop/src/contexts/ThemeContext.tsx +++ b/ui/desktop/src/contexts/ThemeContext.tsx @@ -1,5 +1,17 @@ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; -import { applyThemeTokens, buildMcpHostStyles, getResolvedTheme } from '../theme/theme-tokens'; +import { + buildMcpHostStyles, + getResolvedTheme, + lightTokens, + darkTokens, +} from '../theme/theme-tokens'; +import { + getActiveThemeId, + setActiveThemeId, + getThemePreset, + resolvePresetTokens, +} from '../themes/presets'; +import type { ThemePreset } from '../themes/presets/types'; import type { McpUiHostStyles } from '@modelcontextprotocol/ext-apps/app-bridge'; type ThemePreference = 'light' | 'dark' | 'system'; @@ -10,6 +22,9 @@ interface ThemeContextValue { setUserThemePreference: (pref: ThemePreference) => void; resolvedTheme: ResolvedTheme; mcpHostStyles: McpUiHostStyles; + refreshTokens: () => void; + activePresetId: string | null; + applyPreset: (presetId: string | null) => void; } const ThemeContext = createContext(null); @@ -54,6 +69,34 @@ function applyThemeToDocument(theme: ResolvedTheme): void { document.documentElement.classList.remove(toRemove); } +/** + * Apply resolved tokens to :root. If a preset is active its color overrides + * are merged on top of the goose-classic defaults. Any per-key localStorage + * overrides (`theme-overrides`) are applied last. + */ +function applyResolvedTokens(theme: ResolvedTheme, preset: ThemePreset | undefined): void { + const root = document.documentElement; + + // Start with full defaults or preset-merged tokens + const tokens = preset ? resolvePresetTokens(preset, theme) : (theme === 'dark' ? darkTokens : lightTokens); + + // Layer any per-key localStorage overrides on top + let merged = { ...tokens }; + const stored = localStorage.getItem('theme-overrides'); + if (stored) { + try { + const overrides = JSON.parse(stored); + merged = { ...merged, ...(overrides[theme] ?? {}) }; + } catch { + // ignore bad JSON + } + } + + for (const [key, value] of Object.entries(merged)) { + root.style.setProperty(key, value as string); + } +} + // Built once — light-dark() values are theme-independent const mcpHostStyles = buildMcpHostStyles(); @@ -65,6 +108,11 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const [userThemePreference, setUserThemePreferenceState] = useState(loadThemePreference); const [resolvedTheme, setResolvedTheme] = useState(getResolvedTheme); + const [activePresetId, setActivePresetIdState] = useState( + () => getActiveThemeId() + ); + // Bumped to force re-application of tokens (e.g., after custom color save) + const [tokenVersion, setTokenVersion] = useState(0); const setUserThemePreference = useCallback((preference: ThemePreference) => { setUserThemePreferenceState(preference); @@ -73,7 +121,6 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const resolved = resolveTheme(preference); setResolvedTheme(resolved); - // Broadcast to other windows via Electron window.electron?.broadcastThemeChange({ mode: resolved, useSystemTheme: preference === 'system', @@ -86,21 +133,50 @@ export function ThemeProvider({ children }: ThemeProviderProps) { if (userThemePreference !== 'system') return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - const handleChange = () => { - setResolvedTheme(getSystemTheme()); - }; + const handleChange = () => setResolvedTheme(getSystemTheme()); mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, [userThemePreference]); + // Re-read localStorage overrides and re-apply tokens in this window + const refreshTokens = useCallback(() => { + setTokenVersion((v) => v + 1); + window.electron?.broadcastThemeChange({ + mode: resolvedTheme, + useSystemTheme: userThemePreference === 'system', + theme: resolvedTheme, + tokensUpdated: true, + }); + }, [resolvedTheme, userThemePreference]); + + // Apply a preset (or null to reset to goose-classic defaults) + const applyPreset = useCallback( + (presetId: string | null) => { + setActiveThemeId(presetId); + setActivePresetIdState(presetId); + setTokenVersion((v) => v + 1); + + window.electron?.broadcastThemeChange({ + mode: resolvedTheme, + useSystemTheme: userThemePreference === 'system', + theme: resolvedTheme, + tokensUpdated: true, + }); + }, + [resolvedTheme, userThemePreference] + ); + // Listen for theme changes from other windows (via Electron IPC) useEffect(() => { if (!window.electron) return; const handleThemeChanged = (_event: unknown, ...args: unknown[]) => { - const themeData = args[0] as { useSystemTheme: boolean; theme: string }; + const themeData = args[0] as { + useSystemTheme: boolean; + theme: string; + tokensUpdated?: boolean; + }; const newPreference: ThemePreference = themeData.useSystemTheme ? 'system' : themeData.theme === 'dark' @@ -110,6 +186,12 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setUserThemePreferenceState(newPreference); saveThemePreference(newPreference); setResolvedTheme(resolveTheme(newPreference)); + + if (themeData.tokensUpdated) { + // Re-read active preset from localStorage (may have changed in another window) + setActivePresetIdState(getActiveThemeId()); + setTokenVersion((v) => v + 1); + } }; window.electron.on('theme-changed', handleThemeChanged); @@ -118,17 +200,21 @@ export function ThemeProvider({ children }: ThemeProviderProps) { }; }, []); - // Apply theme class and CSS tokens whenever resolvedTheme changes + // Apply theme class and CSS tokens whenever resolvedTheme or tokens change useEffect(() => { applyThemeToDocument(resolvedTheme); - applyThemeTokens(resolvedTheme); - }, [resolvedTheme]); + const preset = activePresetId ? getThemePreset(activePresetId) : undefined; + applyResolvedTokens(resolvedTheme, preset); + }, [resolvedTheme, activePresetId, tokenVersion]); const value: ThemeContextValue = { userThemePreference, setUserThemePreference, resolvedTheme, mcpHostStyles, + refreshTokens, + activePresetId, + applyPreset, }; return {children}; diff --git a/ui/desktop/src/themes/presets/dracula.ts b/ui/desktop/src/themes/presets/dracula.ts new file mode 100644 index 000000000000..09071e86c3e7 --- /dev/null +++ b/ui/desktop/src/themes/presets/dracula.ts @@ -0,0 +1,63 @@ +/** + * Dracula Theme + * A dark theme with vibrant colors + */ + +import { ThemePreset } from './types'; + +export const dracula: ThemePreset = { + id: 'dracula', + name: 'Dracula', + author: 'Dracula Theme', + description: 'A dark theme with vibrant, high-contrast colors perfect for long coding sessions', + tags: ['dark', 'colorful', 'high-contrast'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#f8f8f2', + 'color-background-secondary': '#f0f0eb', + 'color-background-tertiary': '#e6e6e1', + 'color-background-inverse': '#282a36', + 'color-background-danger': '#ff5555', + 'color-background-info': '#8be9fd', + + 'color-border-primary': '#e6e6e1', + 'color-border-secondary': '#e6e6e1', + 'color-border-danger': '#ff5555', + 'color-border-info': '#8be9fd', + + 'color-text-primary': '#282a36', + 'color-text-secondary': '#6272a4', + 'color-text-inverse': '#f8f8f2', + 'color-text-danger': '#ff5555', + 'color-text-success': '#50fa7b', + 'color-text-warning': '#f1fa8c', + 'color-text-info': '#8be9fd', + + 'color-ring-primary': '#e6e6e1', + }, + dark: { + 'color-background-primary': '#282a36', + 'color-background-secondary': '#343746', + 'color-background-tertiary': '#44475a', + 'color-background-inverse': '#f8f8f2', + 'color-background-danger': '#ff5555', + 'color-background-info': '#8be9fd', + + 'color-border-primary': '#44475a', + 'color-border-secondary': '#6272a4', + 'color-border-danger': '#ff5555', + 'color-border-info': '#8be9fd', + + 'color-text-primary': '#f8f8f2', + 'color-text-secondary': '#f8f8f2', + 'color-text-inverse': '#282a36', + 'color-text-danger': '#ff5555', + 'color-text-success': '#50fa7b', + 'color-text-warning': '#f1fa8c', + 'color-text-info': '#8be9fd', + + 'color-ring-primary': '#6272a4', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/github.ts b/ui/desktop/src/themes/presets/github.ts new file mode 100644 index 000000000000..03b6cc32c07b --- /dev/null +++ b/ui/desktop/src/themes/presets/github.ts @@ -0,0 +1,63 @@ +/** + * GitHub Theme + * Clean and familiar GitHub colors + */ + +import { ThemePreset } from './types'; + +export const github: ThemePreset = { + id: 'github', + name: 'GitHub', + author: 'GitHub', + description: 'Clean, familiar colors from GitHub - professional and easy on the eyes', + tags: ['light', 'dark', 'minimal', 'modern'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#ffffff', + 'color-background-secondary': '#f6f8fa', + 'color-background-tertiary': '#eaeef2', + 'color-background-inverse': '#24292f', + 'color-background-danger': '#d1242f', + 'color-background-info': '#0969da', + + 'color-border-primary': '#d0d7de', + 'color-border-secondary': '#d0d7de', + 'color-border-danger': '#d1242f', + 'color-border-info': '#0969da', + + 'color-text-primary': '#24292f', + 'color-text-secondary': '#57606a', + 'color-text-inverse': '#ffffff', + 'color-text-danger': '#d1242f', + 'color-text-success': '#1a7f37', + 'color-text-warning': '#9a6700', + 'color-text-info': '#0969da', + + 'color-ring-primary': '#d0d7de', + }, + dark: { + 'color-background-primary': '#0d1117', + 'color-background-secondary': '#161b22', + 'color-background-tertiary': '#21262d', + 'color-background-inverse': '#f0f6fc', + 'color-background-danger': '#da3633', + 'color-background-info': '#58a6ff', + + 'color-border-primary': '#30363d', + 'color-border-secondary': '#484f58', + 'color-border-danger': '#da3633', + 'color-border-info': '#58a6ff', + + 'color-text-primary': '#e6edf3', + 'color-text-secondary': '#7d8590', + 'color-text-inverse': '#0d1117', + 'color-text-danger': '#ff7b72', + 'color-text-success': '#3fb950', + 'color-text-warning': '#d29922', + 'color-text-info': '#79c0ff', + + 'color-ring-primary': '#484f58', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/goose-classic.ts b/ui/desktop/src/themes/presets/goose-classic.ts new file mode 100644 index 000000000000..804881654bd9 --- /dev/null +++ b/ui/desktop/src/themes/presets/goose-classic.ts @@ -0,0 +1,63 @@ +/** + * Goose Classic Theme + * The default Goose Desktop theme + */ + +import { ThemePreset } from './types'; + +export const gooseClassic: ThemePreset = { + id: 'goose-classic', + name: 'Goose Classic', + author: 'Block', + description: 'The default Goose Desktop theme with clean, professional colors', + tags: ['light', 'dark', 'default'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#ffffff', + 'color-background-secondary': '#f4f6f7', + 'color-background-tertiary': '#e3e6ea', + 'color-background-inverse': '#000000', + 'color-background-danger': '#f94b4b', + 'color-background-info': '#5c98f9', + + 'color-border-primary': '#e3e6ea', + 'color-border-secondary': '#e3e6ea', + 'color-border-danger': '#f94b4b', + 'color-border-info': '#5c98f9', + + 'color-text-primary': '#3f434b', + 'color-text-secondary': '#878787', + 'color-text-inverse': '#ffffff', + 'color-text-danger': '#f94b4b', + 'color-text-success': '#91cb80', + 'color-text-warning': '#fbcd44', + 'color-text-info': '#5c98f9', + + 'color-ring-primary': '#e3e6ea', + }, + dark: { + 'color-background-primary': '#22252a', + 'color-background-secondary': '#3f434b', + 'color-background-tertiary': '#474e57', + 'color-background-inverse': '#cbd1d6', + 'color-background-danger': '#ff6b6b', + 'color-background-info': '#7cacff', + + 'color-border-primary': '#3f434b', + 'color-border-secondary': '#606c7a', + 'color-border-danger': '#ff6b6b', + 'color-border-info': '#7cacff', + + 'color-text-primary': '#ffffff', + 'color-text-secondary': '#878787', + 'color-text-inverse': '#000000', + 'color-text-danger': '#ff6b6b', + 'color-text-success': '#a3d795', + 'color-text-warning': '#ffd966', + 'color-text-info': '#7cacff', + + 'color-ring-primary': '#606c7a', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/gruvbox.ts b/ui/desktop/src/themes/presets/gruvbox.ts new file mode 100644 index 000000000000..a54a68a21a8e --- /dev/null +++ b/ui/desktop/src/themes/presets/gruvbox.ts @@ -0,0 +1,63 @@ +/** + * Gruvbox Theme + * Warm, retro-inspired color palette + */ + +import { ThemePreset } from './types'; + +export const gruvbox: ThemePreset = { + id: 'gruvbox', + name: 'Gruvbox', + author: 'Pavel Pertsev', + description: 'Warm, retro groove colors designed for long coding sessions', + tags: ['dark', 'light', 'warm', 'retro'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#fbf1c7', + 'color-background-secondary': '#f2e5bc', + 'color-background-tertiary': '#ebdbb2', + 'color-background-inverse': '#282828', + 'color-background-danger': '#cc241d', + 'color-background-info': '#458588', + + 'color-border-primary': '#ebdbb2', + 'color-border-secondary': '#d5c4a1', + 'color-border-danger': '#cc241d', + 'color-border-info': '#458588', + + 'color-text-primary': '#3c3836', + 'color-text-secondary': '#7c6f64', + 'color-text-inverse': '#fbf1c7', + 'color-text-danger': '#cc241d', + 'color-text-success': '#98971a', + 'color-text-warning': '#d79921', + 'color-text-info': '#458588', + + 'color-ring-primary': '#d5c4a1', + }, + dark: { + 'color-background-primary': '#282828', + 'color-background-secondary': '#3c3836', + 'color-background-tertiary': '#504945', + 'color-background-inverse': '#fbf1c7', + 'color-background-danger': '#fb4934', + 'color-background-info': '#83a598', + + 'color-border-primary': '#3c3836', + 'color-border-secondary': '#665c54', + 'color-border-danger': '#fb4934', + 'color-border-info': '#83a598', + + 'color-text-primary': '#ebdbb2', + 'color-text-secondary': '#a89984', + 'color-text-inverse': '#282828', + 'color-text-danger': '#fb4934', + 'color-text-success': '#b8bb26', + 'color-text-warning': '#fabd2f', + 'color-text-info': '#83a598', + + 'color-ring-primary': '#665c54', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/high-contrast.ts b/ui/desktop/src/themes/presets/high-contrast.ts new file mode 100644 index 000000000000..e08686e9e586 --- /dev/null +++ b/ui/desktop/src/themes/presets/high-contrast.ts @@ -0,0 +1,63 @@ +/** + * High Contrast Theme + * Maximum contrast for accessibility + */ + +import { ThemePreset } from './types'; + +export const highContrast: ThemePreset = { + id: 'high-contrast', + name: 'High Contrast', + author: 'Block', + description: 'Maximum contrast theme optimized for accessibility and readability', + tags: ['light', 'dark', 'high-contrast', 'accessible'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#ffffff', + 'color-background-secondary': '#f0f0f0', + 'color-background-tertiary': '#e0e0e0', + 'color-background-inverse': '#000000', + 'color-background-danger': '#d32f2f', + 'color-background-info': '#1976d2', + + 'color-border-primary': '#000000', + 'color-border-secondary': '#000000', + 'color-border-danger': '#d32f2f', + 'color-border-info': '#1976d2', + + 'color-text-primary': '#000000', + 'color-text-secondary': '#424242', + 'color-text-inverse': '#ffffff', + 'color-text-danger': '#d32f2f', + 'color-text-success': '#2e7d32', + 'color-text-warning': '#f57c00', + 'color-text-info': '#1976d2', + + 'color-ring-primary': '#000000', + }, + dark: { + 'color-background-primary': '#000000', + 'color-background-secondary': '#1a1a1a', + 'color-background-tertiary': '#2a2a2a', + 'color-background-inverse': '#ffffff', + 'color-background-danger': '#ff5252', + 'color-background-info': '#448aff', + + 'color-border-primary': '#ffffff', + 'color-border-secondary': '#ffffff', + 'color-border-danger': '#ff5252', + 'color-border-info': '#448aff', + + 'color-text-primary': '#ffffff', + 'color-text-secondary': '#e0e0e0', + 'color-text-inverse': '#000000', + 'color-text-danger': '#ff5252', + 'color-text-success': '#69f0ae', + 'color-text-warning': '#ffab40', + 'color-text-info': '#448aff', + + 'color-ring-primary': '#ffffff', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/index.ts b/ui/desktop/src/themes/presets/index.ts new file mode 100644 index 000000000000..75f1e91867c0 --- /dev/null +++ b/ui/desktop/src/themes/presets/index.ts @@ -0,0 +1,143 @@ +/** + * Theme Presets Registry + * + * Central registry of all built-in theme presets, plus helpers for + * custom theme persistence via localStorage. + */ + +import { ThemePreset } from './types'; +import { gooseClassic } from './goose-classic'; +import { nord } from './nord'; +import { dracula } from './dracula'; +import { solarized } from './solarized'; +import { monokai } from './monokai'; +import { github } from './github'; +import { gruvbox } from './gruvbox'; +import { tokyoNight } from './tokyo-night'; +import { oneDark } from './one-dark'; +import { highContrast } from './high-contrast'; +import { lightTokens, darkTokens } from '../../theme/theme-tokens'; +import type { McpUiStyleVariableKey } from '@modelcontextprotocol/ext-apps/app-bridge'; + +// ── Built-in presets ──────────────────────────────────────── + +export const builtInPresets: ThemePreset[] = [ + gooseClassic, + highContrast, + nord, + dracula, + solarized, + monokai, + github, + gruvbox, + tokyoNight, + oneDark, +]; + +// ── Lookup helpers ────────────────────────────────────────── + +export function getAllPresets(): ThemePreset[] { + return [...builtInPresets, ...loadCustomThemes()]; +} + +export function getThemePreset(id: string): ThemePreset | undefined { + return getAllPresets().find((preset) => preset.id === id); +} + +export function getThemePresetsByTag(tag: string): ThemePreset[] { + return getAllPresets().filter((preset) => preset.tags.includes(tag)); +} + +export function getAllTags(): string[] { + const tags = new Set(); + getAllPresets().forEach((preset) => { + preset.tags.forEach((tag) => tags.add(tag)); + }); + return Array.from(tags).sort(); +} + +// ── Merge preset → full token map ────────────────────────── +// +// Presets only define a subset of color keys (without `--` prefix). +// This merges them on top of the goose-classic defaults so every +// McpUiStyleVariableKey has a value. + +export function resolvePresetTokens( + preset: ThemePreset, + mode: 'light' | 'dark' +): Record { + const defaults = mode === 'dark' ? { ...darkTokens } : { ...lightTokens }; + const overrides = mode === 'light' ? preset.colors.light : preset.colors.dark; + + for (const [key, value] of Object.entries(overrides)) { + // Preset keys are stored without `--` prefix; token keys have it + const tokenKey = key.startsWith('--') ? key : `--${key}`; + if (tokenKey in defaults) { + (defaults as Record)[tokenKey] = value; + } + } + + return defaults; +} + +// ── Active theme persistence (localStorage) ───────────────── + +const ACTIVE_THEME_KEY = 'goose-active-theme-id'; + +export function getActiveThemeId(): string | null { + return localStorage.getItem(ACTIVE_THEME_KEY); +} + +export function setActiveThemeId(id: string | null): void { + if (id) { + localStorage.setItem(ACTIVE_THEME_KEY, id); + } else { + localStorage.removeItem(ACTIVE_THEME_KEY); + } +} + +// ── Custom theme persistence (localStorage) ───────────────── + +const CUSTOM_THEMES_KEY = 'goose-custom-themes'; + +export function loadCustomThemes(): ThemePreset[] { + try { + const stored = localStorage.getItem(CUSTOM_THEMES_KEY); + if (!stored) return []; + const themes: ThemePreset[] = JSON.parse(stored); + return themes.map((t) => ({ ...t, isCustom: true })); + } catch { + return []; + } +} + +export function saveCustomTheme(theme: ThemePreset): void { + const existing = loadCustomThemes(); + const idx = existing.findIndex((t) => t.id === theme.id); + const updated = idx >= 0 ? existing.map((t, i) => (i === idx ? { ...theme, isCustom: true } : t)) : [...existing, { ...theme, isCustom: true }]; + localStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updated)); +} + +export function deleteCustomTheme(id: string): void { + const existing = loadCustomThemes(); + localStorage.setItem( + CUSTOM_THEMES_KEY, + JSON.stringify(existing.filter((t) => t.id !== id)) + ); +} + +// ── Re-exports ────────────────────────────────────────────── + +export * from './types'; +export { + gooseClassic, + highContrast, + nord, + dracula, + solarized, + monokai, + github, + gruvbox, + tokyoNight, + oneDark, +}; diff --git a/ui/desktop/src/themes/presets/monokai.ts b/ui/desktop/src/themes/presets/monokai.ts new file mode 100644 index 000000000000..78da06f453f9 --- /dev/null +++ b/ui/desktop/src/themes/presets/monokai.ts @@ -0,0 +1,63 @@ +/** + * Monokai Theme + * Developer favorite from Sublime Text + */ + +import { ThemePreset } from './types'; + +export const monokai: ThemePreset = { + id: 'monokai', + name: 'Monokai', + author: 'Wimer Hazenberg', + description: 'Classic developer theme from Sublime Text with vibrant syntax colors', + tags: ['dark', 'colorful', 'retro'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#fafafa', + 'color-background-secondary': '#f5f5f5', + 'color-background-tertiary': '#e8e8e8', + 'color-background-inverse': '#272822', + 'color-background-danger': '#f92672', + 'color-background-info': '#66d9ef', + + 'color-border-primary': '#e8e8e8', + 'color-border-secondary': '#d8d8d8', + 'color-border-danger': '#f92672', + 'color-border-info': '#66d9ef', + + 'color-text-primary': '#272822', + 'color-text-secondary': '#75715e', + 'color-text-inverse': '#f8f8f2', + 'color-text-danger': '#f92672', + 'color-text-success': '#a6e22e', + 'color-text-warning': '#e6db74', + 'color-text-info': '#66d9ef', + + 'color-ring-primary': '#d8d8d8', + }, + dark: { + 'color-background-primary': '#272822', + 'color-background-secondary': '#3e3d32', + 'color-background-tertiary': '#49483e', + 'color-background-inverse': '#f8f8f2', + 'color-background-danger': '#f92672', + 'color-background-info': '#66d9ef', + + 'color-border-primary': '#3e3d32', + 'color-border-secondary': '#75715e', + 'color-border-danger': '#f92672', + 'color-border-info': '#66d9ef', + + 'color-text-primary': '#f8f8f2', + 'color-text-secondary': '#75715e', + 'color-text-inverse': '#272822', + 'color-text-danger': '#f92672', + 'color-text-success': '#a6e22e', + 'color-text-warning': '#e6db74', + 'color-text-info': '#66d9ef', + + 'color-ring-primary': '#75715e', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/nord.ts b/ui/desktop/src/themes/presets/nord.ts new file mode 100644 index 000000000000..20bd2c480909 --- /dev/null +++ b/ui/desktop/src/themes/presets/nord.ts @@ -0,0 +1,63 @@ +/** + * Nord Theme + * Arctic, north-bluish color palette + */ + +import { ThemePreset } from './types'; + +export const nord: ThemePreset = { + id: 'nord', + name: 'Nord', + author: 'Arctic Ice Studio', + description: 'An arctic, north-bluish color palette with clean and elegant design', + tags: ['dark', 'light', 'cool', 'minimal'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#eceff4', + 'color-background-secondary': '#e5e9f0', + 'color-background-tertiary': '#d8dee9', + 'color-background-inverse': '#2e3440', + 'color-background-danger': '#bf616a', + 'color-background-info': '#5e81ac', + + 'color-border-primary': '#d8dee9', + 'color-border-secondary': '#d8dee9', + 'color-border-danger': '#bf616a', + 'color-border-info': '#5e81ac', + + 'color-text-primary': '#2e3440', + 'color-text-secondary': '#4c566a', + 'color-text-inverse': '#eceff4', + 'color-text-danger': '#bf616a', + 'color-text-success': '#a3be8c', + 'color-text-warning': '#ebcb8b', + 'color-text-info': '#5e81ac', + + 'color-ring-primary': '#d8dee9', + }, + dark: { + 'color-background-primary': '#2e3440', + 'color-background-secondary': '#3b4252', + 'color-background-tertiary': '#434c5e', + 'color-background-inverse': '#eceff4', + 'color-background-danger': '#bf616a', + 'color-background-info': '#81a1c1', + + 'color-border-primary': '#3b4252', + 'color-border-secondary': '#4c566a', + 'color-border-danger': '#bf616a', + 'color-border-info': '#81a1c1', + + 'color-text-primary': '#eceff4', + 'color-text-secondary': '#d8dee9', + 'color-text-inverse': '#2e3440', + 'color-text-danger': '#bf616a', + 'color-text-success': '#a3be8c', + 'color-text-warning': '#ebcb8b', + 'color-text-info': '#88c0d0', + + 'color-ring-primary': '#4c566a', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/one-dark.ts b/ui/desktop/src/themes/presets/one-dark.ts new file mode 100644 index 000000000000..d4968578dc00 --- /dev/null +++ b/ui/desktop/src/themes/presets/one-dark.ts @@ -0,0 +1,63 @@ +/** + * One Dark Theme + * Atom editor inspired dark theme + */ + +import { ThemePreset } from './types'; + +export const oneDark: ThemePreset = { + id: 'one-dark', + name: 'One Dark', + author: 'Atom', + description: 'Popular dark theme from Atom editor with balanced colors', + tags: ['dark', 'modern', 'minimal'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#fafafa', + 'color-background-secondary': '#f0f0f0', + 'color-background-tertiary': '#e5e5e5', + 'color-background-inverse': '#282c34', + 'color-background-danger': '#e45649', + 'color-background-info': '#4078f2', + + 'color-border-primary': '#e5e5e5', + 'color-border-secondary': '#d0d0d0', + 'color-border-danger': '#e45649', + 'color-border-info': '#4078f2', + + 'color-text-primary': '#383a42', + 'color-text-secondary': '#a0a1a7', + 'color-text-inverse': '#fafafa', + 'color-text-danger': '#e45649', + 'color-text-success': '#50a14f', + 'color-text-warning': '#c18401', + 'color-text-info': '#4078f2', + + 'color-ring-primary': '#d0d0d0', + }, + dark: { + 'color-background-primary': '#282c34', + 'color-background-secondary': '#21252b', + 'color-background-tertiary': '#2c313c', + 'color-background-inverse': '#abb2bf', + 'color-background-danger': '#e06c75', + 'color-background-info': '#61afef', + + 'color-border-primary': '#21252b', + 'color-border-secondary': '#3e4451', + 'color-border-danger': '#e06c75', + 'color-border-info': '#61afef', + + 'color-text-primary': '#abb2bf', + 'color-text-secondary': '#5c6370', + 'color-text-inverse': '#282c34', + 'color-text-danger': '#e06c75', + 'color-text-success': '#98c379', + 'color-text-warning': '#e5c07b', + 'color-text-info': '#61afef', + + 'color-ring-primary': '#3e4451', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/solarized.ts b/ui/desktop/src/themes/presets/solarized.ts new file mode 100644 index 000000000000..341723497ca9 --- /dev/null +++ b/ui/desktop/src/themes/presets/solarized.ts @@ -0,0 +1,63 @@ +/** + * Solarized Theme + * Precision colors for machines and people + */ + +import { ThemePreset } from './types'; + +export const solarized: ThemePreset = { + id: 'solarized', + name: 'Solarized', + author: 'Ethan Schoonover', + description: 'Precision colors for machines and people - designed for optimal readability', + tags: ['light', 'dark', 'minimal', 'retro'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#fdf6e3', + 'color-background-secondary': '#eee8d5', + 'color-background-tertiary': '#e3dcc3', + 'color-background-inverse': '#002b36', + 'color-background-danger': '#dc322f', + 'color-background-info': '#268bd2', + + 'color-border-primary': '#e3dcc3', + 'color-border-secondary': '#d3cdb3', + 'color-border-danger': '#dc322f', + 'color-border-info': '#268bd2', + + 'color-text-primary': '#657b83', + 'color-text-secondary': '#93a1a1', + 'color-text-inverse': '#fdf6e3', + 'color-text-danger': '#dc322f', + 'color-text-success': '#859900', + 'color-text-warning': '#b58900', + 'color-text-info': '#268bd2', + + 'color-ring-primary': '#d3cdb3', + }, + dark: { + 'color-background-primary': '#002b36', + 'color-background-secondary': '#073642', + 'color-background-tertiary': '#0d4654', + 'color-background-inverse': '#fdf6e3', + 'color-background-danger': '#dc322f', + 'color-background-info': '#268bd2', + + 'color-border-primary': '#073642', + 'color-border-secondary': '#586e75', + 'color-border-danger': '#dc322f', + 'color-border-info': '#268bd2', + + 'color-text-primary': '#839496', + 'color-text-secondary': '#657b83', + 'color-text-inverse': '#002b36', + 'color-text-danger': '#dc322f', + 'color-text-success': '#859900', + 'color-text-warning': '#b58900', + 'color-text-info': '#268bd2', + + 'color-ring-primary': '#586e75', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/tokyo-night.ts b/ui/desktop/src/themes/presets/tokyo-night.ts new file mode 100644 index 000000000000..e303db43438b --- /dev/null +++ b/ui/desktop/src/themes/presets/tokyo-night.ts @@ -0,0 +1,63 @@ +/** + * Tokyo Night Theme + * Modern, vibrant night theme + */ + +import { ThemePreset } from './types'; + +export const tokyoNight: ThemePreset = { + id: 'tokyo-night', + name: 'Tokyo Night', + author: 'Folke Lemaitre', + description: 'A clean, dark theme inspired by the lights of Tokyo at night', + tags: ['dark', 'modern', 'colorful'], + version: '1.0.0', + colors: { + light: { + 'color-background-primary': '#d5d6db', + 'color-background-secondary': '#cbccd1', + 'color-background-tertiary': '#c4c8da', + 'color-background-inverse': '#1a1b26', + 'color-background-danger': '#f52a65', + 'color-background-info': '#2ac3de', + + 'color-border-primary': '#c4c8da', + 'color-border-secondary': '#a8aecb', + 'color-border-danger': '#f52a65', + 'color-border-info': '#2ac3de', + + 'color-text-primary': '#343b58', + 'color-text-secondary': '#565a6e', + 'color-text-inverse': '#d5d6db', + 'color-text-danger': '#f52a65', + 'color-text-success': '#33635c', + 'color-text-warning': '#8c6c3e', + 'color-text-info': '#2e7de9', + + 'color-ring-primary': '#a8aecb', + }, + dark: { + 'color-background-primary': '#1a1b26', + 'color-background-secondary': '#24283b', + 'color-background-tertiary': '#414868', + 'color-background-inverse': '#c0caf5', + 'color-background-danger': '#f7768e', + 'color-background-info': '#7dcfff', + + 'color-border-primary': '#24283b', + 'color-border-secondary': '#414868', + 'color-border-danger': '#f7768e', + 'color-border-info': '#7dcfff', + + 'color-text-primary': '#c0caf5', + 'color-text-secondary': '#565f89', + 'color-text-inverse': '#1a1b26', + 'color-text-danger': '#f7768e', + 'color-text-success': '#9ece6a', + 'color-text-warning': '#e0af68', + 'color-text-info': '#7aa2f7', + + 'color-ring-primary': '#414868', + }, + }, +}; diff --git a/ui/desktop/src/themes/presets/types.ts b/ui/desktop/src/themes/presets/types.ts new file mode 100644 index 000000000000..992feb3ac7db --- /dev/null +++ b/ui/desktop/src/themes/presets/types.ts @@ -0,0 +1,31 @@ +/** + * Theme Preset Types + * + * Presets define partial color overrides (keyed WITHOUT the `--` prefix, + * e.g. `color-background-primary`). At apply-time these are merged on top + * of the full goose-classic defaults from theme-tokens.ts. + */ + +export interface ThemePreset { + id: string; + name: string; + author: string; + description: string; + tags: string[]; + thumbnail?: string; + colors: { + light: Record; + dark: Record; + }; + version: string; + isCustom?: boolean; +} + +export type ThemeCategory = + | 'dark' + | 'light' + | 'high-contrast' + | 'colorful' + | 'minimal' + | 'retro' + | 'modern';