diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs index 091cdb31393e..877392282ed4 100644 --- a/crates/goose-server/src/lib.rs +++ b/crates/goose-server/src/lib.rs @@ -5,6 +5,7 @@ pub mod openapi; pub mod routes; pub mod state; pub mod theme_css; +pub mod theme_presets; pub mod tunnel; // Re-export commonly used items diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index af8796412731..f96d26fbf7ca 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -6,6 +6,7 @@ mod openapi; mod routes; mod state; mod theme_css; +mod theme_presets; mod tunnel; use clap::{Parser, Subcommand}; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 1f347bbd8c4f..3a003abdf0cb 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -360,6 +360,11 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::get_pricing, super::routes::config_management::get_theme_variables, super::routes::config_management::save_theme, + super::routes::config_management::get_theme_presets, + super::routes::config_management::get_active_theme, + super::routes::config_management::apply_theme_preset, + super::routes::config_management::save_custom_theme, + super::routes::config_management::delete_custom_theme, super::routes::prompts::get_prompts, super::routes::prompts::get_prompt, super::routes::prompts::save_prompt, @@ -450,6 +455,12 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::PricingData, super::routes::config_management::ThemeVariablesResponse, super::routes::config_management::SaveThemeRequest, + super::routes::config_management::ThemePresetsResponse, + super::routes::config_management::ThemePreset, + super::routes::config_management::ThemeColorsDto, + super::routes::config_management::ActiveThemeResponse, + super::routes::config_management::ApplyPresetRequest, + super::routes::config_management::SaveCustomThemeRequest, super::routes::prompts::PromptsListResponse, super::routes::prompts::PromptContentResponse, super::routes::prompts::SavePromptRequest, diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 8c684736ebbf..20bb189ffa89 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -1,6 +1,7 @@ use crate::routes::errors::ErrorResponse; use crate::routes::utils::check_provider_configured; use crate::state::AppState; +use crate::theme_presets; use axum::routing::put; use axum::{ extract::Path, @@ -883,6 +884,199 @@ pub async fn save_theme(Json(request): Json) -> Result, + pub colors: ThemeColorsDto, + pub version: String, + #[serde(default)] + pub is_custom: bool, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ThemeColorsDto { + pub light: HashMap, + pub dark: HashMap, +} + +#[derive(Serialize, ToSchema)] +pub struct ThemePresetsResponse { + presets: Vec, +} + +#[utoipa::path( + get, + path = "/theme/presets", + responses( + (status = 200, description = "List of all theme presets (built-in and custom)", body = ThemePresetsResponse) + ) +)] +pub async fn get_theme_presets() -> Json { + let presets = theme_presets::get_all_presets_with_custom() + .into_iter() + .map(|p| ThemePreset { + id: p.id, + name: p.name, + author: p.author, + description: p.description, + tags: p.tags, + colors: ThemeColorsDto { + light: p.colors.light, + dark: p.colors.dark, + }, + version: p.version, + is_custom: p.is_custom, + }) + .collect(); + Json(ThemePresetsResponse { presets }) +} + +#[derive(Serialize, ToSchema)] +pub struct ActiveThemeResponse { + theme_id: Option, +} + +#[utoipa::path( + get, + path = "/theme/active", + responses( + (status = 200, description = "Get the currently active theme ID", body = ActiveThemeResponse) + ) +)] +pub async fn get_active_theme() -> Json { + let active_theme_path = Paths::in_data_dir("active_theme.txt"); + let theme_id = std::fs::read_to_string(&active_theme_path) + .ok() + .map(|s| s.trim().to_string()); + + Json(ActiveThemeResponse { theme_id }) +} + +#[derive(Deserialize, ToSchema)] +pub struct ApplyPresetRequest { + preset_id: String, +} + +#[utoipa::path( + post, + path = "/theme/apply-preset", + request_body = ApplyPresetRequest, + responses( + (status = 200, description = "Theme preset applied successfully", body = String), + (status = 404, description = "Theme preset not found"), + (status = 500, description = "Failed to apply theme preset") + ) +)] +pub async fn apply_theme_preset( + Json(request): Json, +) -> Result, ErrorResponse> { + let preset = theme_presets::get_preset(&request.preset_id) + .ok_or_else(|| ErrorResponse::not_found(format!("Theme preset '{}' not found", request.preset_id)))?; + + // Convert preset to CSS format + let mut css_lines = Vec::new(); + + // Light mode + css_lines.push(":root {".to_string()); + for (key, value) in &preset.colors.light { + css_lines.push(format!(" --{}: {};", key, value)); + } + css_lines.push("}".to_string()); + css_lines.push("".to_string()); + + // Dark mode + css_lines.push(".dark {".to_string()); + for (key, value) in &preset.colors.dark { + css_lines.push(format!(" --{}: {};", key, value)); + } + css_lines.push("}".to_string()); + + let css = css_lines.join("\n"); + + // Save to theme.css + let theme_path = Paths::in_data_dir("theme.css"); + std::fs::write(&theme_path, css)?; + + // Store the active theme ID + let active_theme_path = Paths::in_data_dir("active_theme.txt"); + std::fs::write(&active_theme_path, &request.preset_id)?; + + Ok(Json(format!("Applied theme preset: {}", preset.name))) +} + +#[derive(Deserialize, ToSchema)] +pub struct SaveCustomThemeRequest { + pub id: String, + pub name: String, + pub author: String, + pub description: String, + pub tags: Vec, + pub colors: ThemeColorsDto, +} + +#[utoipa::path( + post, + path = "/theme/save-custom", + request_body = SaveCustomThemeRequest, + responses( + (status = 200, description = "Custom theme saved successfully", body = String), + (status = 500, description = "Failed to save custom theme") + ) +)] +pub async fn save_custom_theme( + Json(request): Json, +) -> Result, ErrorResponse> { + let theme = theme_presets::ThemePreset { + id: request.id.clone(), + name: request.name, + author: request.author, + description: request.description, + tags: request.tags, + colors: theme_presets::ThemeColors { + light: request.colors.light, + dark: request.colors.dark, + }, + version: "1.0.0".to_string(), + is_custom: true, + }; + + theme_presets::save_custom_theme(theme) + .map_err(|e| ErrorResponse::internal(format!("Failed to save custom theme: {}", e)))?; + + Ok(Json(format!("Custom theme '{}' saved successfully", request.id))) +} + +#[utoipa::path( + delete, + path = "/theme/saved/{id}", + params( + ("id" = String, Path, description = "Theme ID to delete") + ), + responses( + (status = 200, description = "Custom theme deleted successfully", body = String), + (status = 404, description = "Theme not found"), + (status = 500, description = "Failed to delete theme") + ) +)] +pub async fn delete_custom_theme( + Path(id): Path, +) -> Result, ErrorResponse> { + theme_presets::delete_custom_theme(&id) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ErrorResponse::not_found(format!("Theme '{}' not found", id)) + } else { + ErrorResponse::internal(format!("Failed to delete theme: {}", e)) + } + })?; + + Ok(Json(format!("Custom theme '{}' deleted successfully", id))) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/config", get(read_all_config)) @@ -917,6 +1111,11 @@ pub fn routes(state: Arc) -> Router { ) .route("/theme/variables", get(get_theme_variables)) .route("/theme/save", post(save_theme)) + .route("/theme/presets", get(get_theme_presets)) + .route("/theme/active", get(get_active_theme)) + .route("/theme/apply-preset", post(apply_theme_preset)) + .route("/theme/save-custom", post(save_custom_theme)) + .route("/theme/saved/{id}", delete(delete_custom_theme)) .with_state(state) } diff --git a/crates/goose-server/src/theme_presets.rs b/crates/goose-server/src/theme_presets.rs new file mode 100644 index 000000000000..73a69a4dc2da --- /dev/null +++ b/crates/goose-server/src/theme_presets.rs @@ -0,0 +1,671 @@ +//! Theme Presets +//! +//! Built-in theme presets that ship with Goose Desktop. +//! These are embedded in the binary and served via API. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[schema(as = ThemePreset)] +pub struct ThemePreset { + pub id: String, + pub name: String, + pub author: String, + pub description: String, + pub tags: Vec, + pub colors: ThemeColors, + pub version: String, + #[serde(default)] + pub is_custom: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[schema(as = ThemeColors)] +pub struct ThemeColors { + pub light: HashMap, + pub dark: HashMap, +} + +/// Get all built-in theme presets +pub fn get_all_presets() -> Vec { + vec![ + goose_classic(), + high_contrast(), + nord(), + dracula(), + solarized(), + monokai(), + github(), + gruvbox(), + tokyo_night(), + one_dark(), + ] +} + +/// Get a specific preset by ID (includes both built-in and custom themes) +pub fn get_preset(id: &str) -> Option { + get_all_presets_with_custom().into_iter().find(|p| p.id == id) +} + +/// Goose Classic Theme - The default theme +fn goose_classic() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#ffffff".to_string()); + light.insert("color-background-secondary".to_string(), "#f4f6f7".to_string()); + light.insert("color-background-tertiary".to_string(), "#e3e6ea".to_string()); + light.insert("color-background-inverse".to_string(), "#000000".to_string()); + light.insert("color-background-danger".to_string(), "#f94b4b".to_string()); + light.insert("color-background-info".to_string(), "#5c98f9".to_string()); + light.insert("color-border-primary".to_string(), "#e3e6ea".to_string()); + light.insert("color-border-secondary".to_string(), "#e3e6ea".to_string()); + light.insert("color-border-danger".to_string(), "#f94b4b".to_string()); + light.insert("color-border-info".to_string(), "#5c98f9".to_string()); + light.insert("color-text-primary".to_string(), "#3f434b".to_string()); + light.insert("color-text-secondary".to_string(), "#878787".to_string()); + light.insert("color-text-inverse".to_string(), "#ffffff".to_string()); + light.insert("color-text-danger".to_string(), "#f94b4b".to_string()); + light.insert("color-text-success".to_string(), "#91cb80".to_string()); + light.insert("color-text-warning".to_string(), "#fbcd44".to_string()); + light.insert("color-text-info".to_string(), "#5c98f9".to_string()); + light.insert("color-ring-primary".to_string(), "#e3e6ea".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#22252a".to_string()); + dark.insert("color-background-secondary".to_string(), "#3f434b".to_string()); + dark.insert("color-background-tertiary".to_string(), "#474e57".to_string()); + dark.insert("color-background-inverse".to_string(), "#cbd1d6".to_string()); + dark.insert("color-background-danger".to_string(), "#ff6b6b".to_string()); + dark.insert("color-background-info".to_string(), "#7cacff".to_string()); + dark.insert("color-border-primary".to_string(), "#3f434b".to_string()); + dark.insert("color-border-secondary".to_string(), "#606c7a".to_string()); + dark.insert("color-border-danger".to_string(), "#ff6b6b".to_string()); + dark.insert("color-border-info".to_string(), "#7cacff".to_string()); + dark.insert("color-text-primary".to_string(), "#ffffff".to_string()); + dark.insert("color-text-secondary".to_string(), "#878787".to_string()); + dark.insert("color-text-inverse".to_string(), "#000000".to_string()); + dark.insert("color-text-danger".to_string(), "#ff6b6b".to_string()); + dark.insert("color-text-success".to_string(), "#a3d795".to_string()); + dark.insert("color-text-warning".to_string(), "#ffd966".to_string()); + dark.insert("color-text-info".to_string(), "#7cacff".to_string()); + dark.insert("color-ring-primary".to_string(), "#606c7a".to_string()); + + ThemePreset { + id: "goose-classic".to_string(), + name: "Goose Classic".to_string(), + author: "Block".to_string(), + description: "The default Goose Desktop theme with clean, professional colors".to_string(), + tags: vec!["light".to_string(), "dark".to_string(), "default".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// Nord Theme - Arctic color palette +fn nord() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#eceff4".to_string()); + light.insert("color-background-secondary".to_string(), "#e5e9f0".to_string()); + light.insert("color-background-tertiary".to_string(), "#d8dee9".to_string()); + light.insert("color-background-inverse".to_string(), "#2e3440".to_string()); + light.insert("color-background-danger".to_string(), "#bf616a".to_string()); + light.insert("color-background-info".to_string(), "#5e81ac".to_string()); + light.insert("color-border-primary".to_string(), "#d8dee9".to_string()); + light.insert("color-border-secondary".to_string(), "#d8dee9".to_string()); + light.insert("color-border-danger".to_string(), "#bf616a".to_string()); + light.insert("color-border-info".to_string(), "#5e81ac".to_string()); + light.insert("color-text-primary".to_string(), "#2e3440".to_string()); + light.insert("color-text-secondary".to_string(), "#4c566a".to_string()); + light.insert("color-text-inverse".to_string(), "#eceff4".to_string()); + light.insert("color-text-danger".to_string(), "#bf616a".to_string()); + light.insert("color-text-success".to_string(), "#a3be8c".to_string()); + light.insert("color-text-warning".to_string(), "#ebcb8b".to_string()); + light.insert("color-text-info".to_string(), "#5e81ac".to_string()); + light.insert("color-ring-primary".to_string(), "#d8dee9".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#2e3440".to_string()); + dark.insert("color-background-secondary".to_string(), "#3b4252".to_string()); + dark.insert("color-background-tertiary".to_string(), "#434c5e".to_string()); + dark.insert("color-background-inverse".to_string(), "#eceff4".to_string()); + dark.insert("color-background-danger".to_string(), "#bf616a".to_string()); + dark.insert("color-background-info".to_string(), "#81a1c1".to_string()); + dark.insert("color-border-primary".to_string(), "#3b4252".to_string()); + dark.insert("color-border-secondary".to_string(), "#4c566a".to_string()); + dark.insert("color-border-danger".to_string(), "#bf616a".to_string()); + dark.insert("color-border-info".to_string(), "#81a1c1".to_string()); + dark.insert("color-text-primary".to_string(), "#eceff4".to_string()); + dark.insert("color-text-secondary".to_string(), "#d8dee9".to_string()); + dark.insert("color-text-inverse".to_string(), "#2e3440".to_string()); + dark.insert("color-text-danger".to_string(), "#bf616a".to_string()); + dark.insert("color-text-success".to_string(), "#a3be8c".to_string()); + dark.insert("color-text-warning".to_string(), "#ebcb8b".to_string()); + dark.insert("color-text-info".to_string(), "#88c0d0".to_string()); + dark.insert("color-ring-primary".to_string(), "#4c566a".to_string()); + + ThemePreset { + id: "nord".to_string(), + name: "Nord".to_string(), + author: "Arctic Ice Studio".to_string(), + description: "An arctic, north-bluish color palette with clean and elegant design".to_string(), + tags: vec!["dark".to_string(), "light".to_string(), "cool".to_string(), "minimal".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// Dracula Theme - Vibrant dark theme +fn dracula() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#f8f8f2".to_string()); + light.insert("color-background-secondary".to_string(), "#f0f0eb".to_string()); + light.insert("color-background-tertiary".to_string(), "#e6e6e1".to_string()); + light.insert("color-background-inverse".to_string(), "#282a36".to_string()); + light.insert("color-background-danger".to_string(), "#ff5555".to_string()); + light.insert("color-background-info".to_string(), "#8be9fd".to_string()); + light.insert("color-border-primary".to_string(), "#e6e6e1".to_string()); + light.insert("color-border-secondary".to_string(), "#e6e6e1".to_string()); + light.insert("color-border-danger".to_string(), "#ff5555".to_string()); + light.insert("color-border-info".to_string(), "#8be9fd".to_string()); + light.insert("color-text-primary".to_string(), "#282a36".to_string()); + light.insert("color-text-secondary".to_string(), "#6272a4".to_string()); + light.insert("color-text-inverse".to_string(), "#f8f8f2".to_string()); + light.insert("color-text-danger".to_string(), "#ff5555".to_string()); + light.insert("color-text-success".to_string(), "#50fa7b".to_string()); + light.insert("color-text-warning".to_string(), "#f1fa8c".to_string()); + light.insert("color-text-info".to_string(), "#8be9fd".to_string()); + light.insert("color-ring-primary".to_string(), "#e6e6e1".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#282a36".to_string()); + dark.insert("color-background-secondary".to_string(), "#343746".to_string()); + dark.insert("color-background-tertiary".to_string(), "#44475a".to_string()); + dark.insert("color-background-inverse".to_string(), "#f8f8f2".to_string()); + dark.insert("color-background-danger".to_string(), "#ff5555".to_string()); + dark.insert("color-background-info".to_string(), "#8be9fd".to_string()); + dark.insert("color-border-primary".to_string(), "#44475a".to_string()); + dark.insert("color-border-secondary".to_string(), "#6272a4".to_string()); + dark.insert("color-border-danger".to_string(), "#ff5555".to_string()); + dark.insert("color-border-info".to_string(), "#8be9fd".to_string()); + dark.insert("color-text-primary".to_string(), "#f8f8f2".to_string()); + dark.insert("color-text-secondary".to_string(), "#f8f8f2".to_string()); + dark.insert("color-text-inverse".to_string(), "#282a36".to_string()); + dark.insert("color-text-danger".to_string(), "#ff5555".to_string()); + dark.insert("color-text-success".to_string(), "#50fa7b".to_string()); + dark.insert("color-text-warning".to_string(), "#f1fa8c".to_string()); + dark.insert("color-text-info".to_string(), "#8be9fd".to_string()); + dark.insert("color-ring-primary".to_string(), "#6272a4".to_string()); + + ThemePreset { + id: "dracula".to_string(), + name: "Dracula".to_string(), + author: "Dracula Theme".to_string(), + description: "A dark theme with vibrant, high-contrast colors perfect for long coding sessions".to_string(), + tags: vec!["dark".to_string(), "colorful".to_string(), "high-contrast".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// High Contrast Theme - Maximum contrast for accessibility +fn high_contrast() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#ffffff".to_string()); + light.insert("color-background-secondary".to_string(), "#f0f0f0".to_string()); + light.insert("color-background-tertiary".to_string(), "#e0e0e0".to_string()); + light.insert("color-background-inverse".to_string(), "#000000".to_string()); + light.insert("color-background-danger".to_string(), "#d32f2f".to_string()); + light.insert("color-background-info".to_string(), "#1976d2".to_string()); + light.insert("color-border-primary".to_string(), "#000000".to_string()); + light.insert("color-border-secondary".to_string(), "#000000".to_string()); + light.insert("color-border-danger".to_string(), "#d32f2f".to_string()); + light.insert("color-border-info".to_string(), "#1976d2".to_string()); + light.insert("color-text-primary".to_string(), "#000000".to_string()); + light.insert("color-text-secondary".to_string(), "#424242".to_string()); + light.insert("color-text-inverse".to_string(), "#ffffff".to_string()); + light.insert("color-text-danger".to_string(), "#d32f2f".to_string()); + light.insert("color-text-success".to_string(), "#2e7d32".to_string()); + light.insert("color-text-warning".to_string(), "#f57c00".to_string()); + light.insert("color-text-info".to_string(), "#1976d2".to_string()); + light.insert("color-ring-primary".to_string(), "#000000".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#000000".to_string()); + dark.insert("color-background-secondary".to_string(), "#1a1a1a".to_string()); + dark.insert("color-background-tertiary".to_string(), "#2a2a2a".to_string()); + dark.insert("color-background-inverse".to_string(), "#ffffff".to_string()); + dark.insert("color-background-danger".to_string(), "#ff5252".to_string()); + dark.insert("color-background-info".to_string(), "#448aff".to_string()); + dark.insert("color-border-primary".to_string(), "#ffffff".to_string()); + dark.insert("color-border-secondary".to_string(), "#ffffff".to_string()); + dark.insert("color-border-danger".to_string(), "#ff5252".to_string()); + dark.insert("color-border-info".to_string(), "#448aff".to_string()); + dark.insert("color-text-primary".to_string(), "#ffffff".to_string()); + dark.insert("color-text-secondary".to_string(), "#e0e0e0".to_string()); + dark.insert("color-text-inverse".to_string(), "#000000".to_string()); + dark.insert("color-text-danger".to_string(), "#ff5252".to_string()); + dark.insert("color-text-success".to_string(), "#69f0ae".to_string()); + dark.insert("color-text-warning".to_string(), "#ffab40".to_string()); + dark.insert("color-text-info".to_string(), "#448aff".to_string()); + dark.insert("color-ring-primary".to_string(), "#ffffff".to_string()); + + ThemePreset { + id: "high-contrast".to_string(), + name: "High Contrast".to_string(), + author: "Block".to_string(), + description: "Maximum contrast theme optimized for accessibility and readability".to_string(), + tags: vec!["light".to_string(), "dark".to_string(), "high-contrast".to_string(), "accessible".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// Solarized Theme - Precision colors for machines and people +fn solarized() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#fdf6e3".to_string()); + light.insert("color-background-secondary".to_string(), "#eee8d5".to_string()); + light.insert("color-background-tertiary".to_string(), "#e3dcc3".to_string()); + light.insert("color-background-inverse".to_string(), "#002b36".to_string()); + light.insert("color-background-danger".to_string(), "#dc322f".to_string()); + light.insert("color-background-info".to_string(), "#268bd2".to_string()); + light.insert("color-border-primary".to_string(), "#e3dcc3".to_string()); + light.insert("color-border-secondary".to_string(), "#d3cdb3".to_string()); + light.insert("color-border-danger".to_string(), "#dc322f".to_string()); + light.insert("color-border-info".to_string(), "#268bd2".to_string()); + light.insert("color-text-primary".to_string(), "#657b83".to_string()); + light.insert("color-text-secondary".to_string(), "#93a1a1".to_string()); + light.insert("color-text-inverse".to_string(), "#fdf6e3".to_string()); + light.insert("color-text-danger".to_string(), "#dc322f".to_string()); + light.insert("color-text-success".to_string(), "#859900".to_string()); + light.insert("color-text-warning".to_string(), "#b58900".to_string()); + light.insert("color-text-info".to_string(), "#268bd2".to_string()); + light.insert("color-ring-primary".to_string(), "#d3cdb3".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#002b36".to_string()); + dark.insert("color-background-secondary".to_string(), "#073642".to_string()); + dark.insert("color-background-tertiary".to_string(), "#0d4654".to_string()); + dark.insert("color-background-inverse".to_string(), "#fdf6e3".to_string()); + dark.insert("color-background-danger".to_string(), "#dc322f".to_string()); + dark.insert("color-background-info".to_string(), "#268bd2".to_string()); + dark.insert("color-border-primary".to_string(), "#073642".to_string()); + dark.insert("color-border-secondary".to_string(), "#586e75".to_string()); + dark.insert("color-border-danger".to_string(), "#dc322f".to_string()); + dark.insert("color-border-info".to_string(), "#268bd2".to_string()); + dark.insert("color-text-primary".to_string(), "#839496".to_string()); + dark.insert("color-text-secondary".to_string(), "#657b83".to_string()); + dark.insert("color-text-inverse".to_string(), "#002b36".to_string()); + dark.insert("color-text-danger".to_string(), "#dc322f".to_string()); + dark.insert("color-text-success".to_string(), "#859900".to_string()); + dark.insert("color-text-warning".to_string(), "#b58900".to_string()); + dark.insert("color-text-info".to_string(), "#268bd2".to_string()); + dark.insert("color-ring-primary".to_string(), "#586e75".to_string()); + + ThemePreset { + id: "solarized".to_string(), + name: "Solarized".to_string(), + author: "Ethan Schoonover".to_string(), + description: "Precision colors for machines and people - designed for optimal readability".to_string(), + tags: vec!["light".to_string(), "dark".to_string(), "minimal".to_string(), "retro".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// Monokai Theme - Classic developer theme from Sublime Text +fn monokai() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#fafafa".to_string()); + light.insert("color-background-secondary".to_string(), "#f5f5f5".to_string()); + light.insert("color-background-tertiary".to_string(), "#e8e8e8".to_string()); + light.insert("color-background-inverse".to_string(), "#272822".to_string()); + light.insert("color-background-danger".to_string(), "#f92672".to_string()); + light.insert("color-background-info".to_string(), "#66d9ef".to_string()); + light.insert("color-border-primary".to_string(), "#e8e8e8".to_string()); + light.insert("color-border-secondary".to_string(), "#d8d8d8".to_string()); + light.insert("color-border-danger".to_string(), "#f92672".to_string()); + light.insert("color-border-info".to_string(), "#66d9ef".to_string()); + light.insert("color-text-primary".to_string(), "#272822".to_string()); + light.insert("color-text-secondary".to_string(), "#75715e".to_string()); + light.insert("color-text-inverse".to_string(), "#f8f8f2".to_string()); + light.insert("color-text-danger".to_string(), "#f92672".to_string()); + light.insert("color-text-success".to_string(), "#a6e22e".to_string()); + light.insert("color-text-warning".to_string(), "#e6db74".to_string()); + light.insert("color-text-info".to_string(), "#66d9ef".to_string()); + light.insert("color-ring-primary".to_string(), "#d8d8d8".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#272822".to_string()); + dark.insert("color-background-secondary".to_string(), "#3e3d32".to_string()); + dark.insert("color-background-tertiary".to_string(), "#49483e".to_string()); + dark.insert("color-background-inverse".to_string(), "#f8f8f2".to_string()); + dark.insert("color-background-danger".to_string(), "#f92672".to_string()); + dark.insert("color-background-info".to_string(), "#66d9ef".to_string()); + dark.insert("color-border-primary".to_string(), "#3e3d32".to_string()); + dark.insert("color-border-secondary".to_string(), "#75715e".to_string()); + dark.insert("color-border-danger".to_string(), "#f92672".to_string()); + dark.insert("color-border-info".to_string(), "#66d9ef".to_string()); + dark.insert("color-text-primary".to_string(), "#f8f8f2".to_string()); + dark.insert("color-text-secondary".to_string(), "#75715e".to_string()); + dark.insert("color-text-inverse".to_string(), "#272822".to_string()); + dark.insert("color-text-danger".to_string(), "#f92672".to_string()); + dark.insert("color-text-success".to_string(), "#a6e22e".to_string()); + dark.insert("color-text-warning".to_string(), "#e6db74".to_string()); + dark.insert("color-text-info".to_string(), "#66d9ef".to_string()); + dark.insert("color-ring-primary".to_string(), "#75715e".to_string()); + + ThemePreset { + id: "monokai".to_string(), + name: "Monokai".to_string(), + author: "Wimer Hazenberg".to_string(), + description: "Classic developer theme from Sublime Text with vibrant syntax colors".to_string(), + tags: vec!["dark".to_string(), "colorful".to_string(), "retro".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// GitHub Theme - Clean and familiar GitHub colors +fn github() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#ffffff".to_string()); + light.insert("color-background-secondary".to_string(), "#f6f8fa".to_string()); + light.insert("color-background-tertiary".to_string(), "#eaeef2".to_string()); + light.insert("color-background-inverse".to_string(), "#24292f".to_string()); + light.insert("color-background-danger".to_string(), "#d1242f".to_string()); + light.insert("color-background-info".to_string(), "#0969da".to_string()); + light.insert("color-border-primary".to_string(), "#d0d7de".to_string()); + light.insert("color-border-secondary".to_string(), "#d0d7de".to_string()); + light.insert("color-border-danger".to_string(), "#d1242f".to_string()); + light.insert("color-border-info".to_string(), "#0969da".to_string()); + light.insert("color-text-primary".to_string(), "#24292f".to_string()); + light.insert("color-text-secondary".to_string(), "#57606a".to_string()); + light.insert("color-text-inverse".to_string(), "#ffffff".to_string()); + light.insert("color-text-danger".to_string(), "#d1242f".to_string()); + light.insert("color-text-success".to_string(), "#1a7f37".to_string()); + light.insert("color-text-warning".to_string(), "#9a6700".to_string()); + light.insert("color-text-info".to_string(), "#0969da".to_string()); + light.insert("color-ring-primary".to_string(), "#d0d7de".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#0d1117".to_string()); + dark.insert("color-background-secondary".to_string(), "#161b22".to_string()); + dark.insert("color-background-tertiary".to_string(), "#21262d".to_string()); + dark.insert("color-background-inverse".to_string(), "#f0f6fc".to_string()); + dark.insert("color-background-danger".to_string(), "#da3633".to_string()); + dark.insert("color-background-info".to_string(), "#58a6ff".to_string()); + dark.insert("color-border-primary".to_string(), "#30363d".to_string()); + dark.insert("color-border-secondary".to_string(), "#484f58".to_string()); + dark.insert("color-border-danger".to_string(), "#da3633".to_string()); + dark.insert("color-border-info".to_string(), "#58a6ff".to_string()); + dark.insert("color-text-primary".to_string(), "#e6edf3".to_string()); + dark.insert("color-text-secondary".to_string(), "#7d8590".to_string()); + dark.insert("color-text-inverse".to_string(), "#0d1117".to_string()); + dark.insert("color-text-danger".to_string(), "#ff7b72".to_string()); + dark.insert("color-text-success".to_string(), "#3fb950".to_string()); + dark.insert("color-text-warning".to_string(), "#d29922".to_string()); + dark.insert("color-text-info".to_string(), "#79c0ff".to_string()); + dark.insert("color-ring-primary".to_string(), "#484f58".to_string()); + + ThemePreset { + id: "github".to_string(), + name: "GitHub".to_string(), + author: "GitHub".to_string(), + description: "Clean, familiar colors from GitHub - professional and easy on the eyes".to_string(), + tags: vec!["light".to_string(), "dark".to_string(), "minimal".to_string(), "modern".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// Gruvbox Theme - Warm, retro-inspired color palette +fn gruvbox() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#fbf1c7".to_string()); + light.insert("color-background-secondary".to_string(), "#f2e5bc".to_string()); + light.insert("color-background-tertiary".to_string(), "#ebdbb2".to_string()); + light.insert("color-background-inverse".to_string(), "#282828".to_string()); + light.insert("color-background-danger".to_string(), "#cc241d".to_string()); + light.insert("color-background-info".to_string(), "#458588".to_string()); + light.insert("color-border-primary".to_string(), "#ebdbb2".to_string()); + light.insert("color-border-secondary".to_string(), "#d5c4a1".to_string()); + light.insert("color-border-danger".to_string(), "#cc241d".to_string()); + light.insert("color-border-info".to_string(), "#458588".to_string()); + light.insert("color-text-primary".to_string(), "#3c3836".to_string()); + light.insert("color-text-secondary".to_string(), "#7c6f64".to_string()); + light.insert("color-text-inverse".to_string(), "#fbf1c7".to_string()); + light.insert("color-text-danger".to_string(), "#cc241d".to_string()); + light.insert("color-text-success".to_string(), "#98971a".to_string()); + light.insert("color-text-warning".to_string(), "#d79921".to_string()); + light.insert("color-text-info".to_string(), "#458588".to_string()); + light.insert("color-ring-primary".to_string(), "#d5c4a1".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#282828".to_string()); + dark.insert("color-background-secondary".to_string(), "#3c3836".to_string()); + dark.insert("color-background-tertiary".to_string(), "#504945".to_string()); + dark.insert("color-background-inverse".to_string(), "#fbf1c7".to_string()); + dark.insert("color-background-danger".to_string(), "#fb4934".to_string()); + dark.insert("color-background-info".to_string(), "#83a598".to_string()); + dark.insert("color-border-primary".to_string(), "#3c3836".to_string()); + dark.insert("color-border-secondary".to_string(), "#665c54".to_string()); + dark.insert("color-border-danger".to_string(), "#fb4934".to_string()); + dark.insert("color-border-info".to_string(), "#83a598".to_string()); + dark.insert("color-text-primary".to_string(), "#ebdbb2".to_string()); + dark.insert("color-text-secondary".to_string(), "#a89984".to_string()); + dark.insert("color-text-inverse".to_string(), "#282828".to_string()); + dark.insert("color-text-danger".to_string(), "#fb4934".to_string()); + dark.insert("color-text-success".to_string(), "#b8bb26".to_string()); + dark.insert("color-text-warning".to_string(), "#fabd2f".to_string()); + dark.insert("color-text-info".to_string(), "#83a598".to_string()); + dark.insert("color-ring-primary".to_string(), "#665c54".to_string()); + + ThemePreset { + id: "gruvbox".to_string(), + name: "Gruvbox".to_string(), + author: "Pavel Pertsev".to_string(), + description: "Warm, retro groove colors designed for long coding sessions".to_string(), + tags: vec!["dark".to_string(), "light".to_string(), "warm".to_string(), "retro".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// Tokyo Night Theme - Modern, vibrant night theme +fn tokyo_night() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#d5d6db".to_string()); + light.insert("color-background-secondary".to_string(), "#cbccd1".to_string()); + light.insert("color-background-tertiary".to_string(), "#c4c8da".to_string()); + light.insert("color-background-inverse".to_string(), "#1a1b26".to_string()); + light.insert("color-background-danger".to_string(), "#f52a65".to_string()); + light.insert("color-background-info".to_string(), "#2ac3de".to_string()); + light.insert("color-border-primary".to_string(), "#c4c8da".to_string()); + light.insert("color-border-secondary".to_string(), "#a8aecb".to_string()); + light.insert("color-border-danger".to_string(), "#f52a65".to_string()); + light.insert("color-border-info".to_string(), "#2ac3de".to_string()); + light.insert("color-text-primary".to_string(), "#343b58".to_string()); + light.insert("color-text-secondary".to_string(), "#565a6e".to_string()); + light.insert("color-text-inverse".to_string(), "#d5d6db".to_string()); + light.insert("color-text-danger".to_string(), "#f52a65".to_string()); + light.insert("color-text-success".to_string(), "#33635c".to_string()); + light.insert("color-text-warning".to_string(), "#8c6c3e".to_string()); + light.insert("color-text-info".to_string(), "#2e7de9".to_string()); + light.insert("color-ring-primary".to_string(), "#a8aecb".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#1a1b26".to_string()); + dark.insert("color-background-secondary".to_string(), "#24283b".to_string()); + dark.insert("color-background-tertiary".to_string(), "#414868".to_string()); + dark.insert("color-background-inverse".to_string(), "#c0caf5".to_string()); + dark.insert("color-background-danger".to_string(), "#f7768e".to_string()); + dark.insert("color-background-info".to_string(), "#7dcfff".to_string()); + dark.insert("color-border-primary".to_string(), "#24283b".to_string()); + dark.insert("color-border-secondary".to_string(), "#414868".to_string()); + dark.insert("color-border-danger".to_string(), "#f7768e".to_string()); + dark.insert("color-border-info".to_string(), "#7dcfff".to_string()); + dark.insert("color-text-primary".to_string(), "#c0caf5".to_string()); + dark.insert("color-text-secondary".to_string(), "#565f89".to_string()); + dark.insert("color-text-inverse".to_string(), "#1a1b26".to_string()); + dark.insert("color-text-danger".to_string(), "#f7768e".to_string()); + dark.insert("color-text-success".to_string(), "#9ece6a".to_string()); + dark.insert("color-text-warning".to_string(), "#e0af68".to_string()); + dark.insert("color-text-info".to_string(), "#7aa2f7".to_string()); + dark.insert("color-ring-primary".to_string(), "#414868".to_string()); + + ThemePreset { + id: "tokyo-night".to_string(), + name: "Tokyo Night".to_string(), + author: "Folke Lemaitre".to_string(), + description: "A clean, dark theme inspired by the lights of Tokyo at night".to_string(), + tags: vec!["dark".to_string(), "modern".to_string(), "colorful".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +/// One Dark Theme - Popular dark theme from Atom editor +fn one_dark() -> ThemePreset { + let mut light = HashMap::new(); + light.insert("color-background-primary".to_string(), "#fafafa".to_string()); + light.insert("color-background-secondary".to_string(), "#f0f0f0".to_string()); + light.insert("color-background-tertiary".to_string(), "#e5e5e5".to_string()); + light.insert("color-background-inverse".to_string(), "#282c34".to_string()); + light.insert("color-background-danger".to_string(), "#e45649".to_string()); + light.insert("color-background-info".to_string(), "#4078f2".to_string()); + light.insert("color-border-primary".to_string(), "#e5e5e5".to_string()); + light.insert("color-border-secondary".to_string(), "#d0d0d0".to_string()); + light.insert("color-border-danger".to_string(), "#e45649".to_string()); + light.insert("color-border-info".to_string(), "#4078f2".to_string()); + light.insert("color-text-primary".to_string(), "#383a42".to_string()); + light.insert("color-text-secondary".to_string(), "#a0a1a7".to_string()); + light.insert("color-text-inverse".to_string(), "#fafafa".to_string()); + light.insert("color-text-danger".to_string(), "#e45649".to_string()); + light.insert("color-text-success".to_string(), "#50a14f".to_string()); + light.insert("color-text-warning".to_string(), "#c18401".to_string()); + light.insert("color-text-info".to_string(), "#4078f2".to_string()); + light.insert("color-ring-primary".to_string(), "#d0d0d0".to_string()); + + let mut dark = HashMap::new(); + dark.insert("color-background-primary".to_string(), "#282c34".to_string()); + dark.insert("color-background-secondary".to_string(), "#21252b".to_string()); + dark.insert("color-background-tertiary".to_string(), "#2c313c".to_string()); + dark.insert("color-background-inverse".to_string(), "#abb2bf".to_string()); + dark.insert("color-background-danger".to_string(), "#e06c75".to_string()); + dark.insert("color-background-info".to_string(), "#61afef".to_string()); + dark.insert("color-border-primary".to_string(), "#21252b".to_string()); + dark.insert("color-border-secondary".to_string(), "#3e4451".to_string()); + dark.insert("color-border-danger".to_string(), "#e06c75".to_string()); + dark.insert("color-border-info".to_string(), "#61afef".to_string()); + dark.insert("color-text-primary".to_string(), "#abb2bf".to_string()); + dark.insert("color-text-secondary".to_string(), "#5c6370".to_string()); + dark.insert("color-text-inverse".to_string(), "#282c34".to_string()); + dark.insert("color-text-danger".to_string(), "#e06c75".to_string()); + dark.insert("color-text-success".to_string(), "#98c379".to_string()); + dark.insert("color-text-warning".to_string(), "#e5c07b".to_string()); + dark.insert("color-text-info".to_string(), "#61afef".to_string()); + dark.insert("color-ring-primary".to_string(), "#3e4451".to_string()); + + ThemePreset { + id: "one-dark".to_string(), + name: "One Dark".to_string(), + author: "Atom".to_string(), + description: "Popular dark theme from Atom editor with balanced colors".to_string(), + tags: vec!["dark".to_string(), "modern".to_string(), "minimal".to_string()], + colors: ThemeColors { light, dark }, + version: "1.0.0".to_string(), + is_custom: false, + } +} + +// ============================================================================ +// Custom Theme Management +// ============================================================================ + +/// Get the path to the saved themes directory +fn get_saved_themes_dir() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Config directory not found"))?; + let themes_dir = config_dir.join("goose").join("data").join("saved_themes"); + + // Create directory if it doesn't exist + if !themes_dir.exists() { + fs::create_dir_all(&themes_dir)?; + } + + Ok(themes_dir) +} + +/// Load all custom themes from the saved_themes directory +pub fn load_custom_themes() -> Vec { + let themes_dir = match get_saved_themes_dir() { + Ok(dir) => dir, + Err(_) => return vec![], + }; + + let mut themes = Vec::new(); + + if let Ok(entries) = fs::read_dir(themes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(mut theme) = serde_json::from_str::(&content) { + theme.is_custom = true; + themes.push(theme); + } + } + } + } + } + + themes +} + +/// Get all presets including both built-in and custom themes +pub fn get_all_presets_with_custom() -> Vec { + let mut presets = get_all_presets(); + let custom_themes = load_custom_themes(); + presets.extend(custom_themes); + presets +} + +/// Save a custom theme +pub fn save_custom_theme(theme: ThemePreset) -> Result<(), std::io::Error> { + let themes_dir = get_saved_themes_dir()?; + let file_path = themes_dir.join(format!("{}.json", theme.id)); + + let json = serde_json::to_string_pretty(&theme)?; + fs::write(file_path, json)?; + + Ok(()) +} + +/// Delete a custom theme by ID +pub fn delete_custom_theme(id: &str) -> Result<(), std::io::Error> { + let themes_dir = get_saved_themes_dir()?; + let file_path = themes_dir.join(format!("{}.json", id)); + + if file_path.exists() { + fs::remove_file(file_path)?; + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Theme not found", + )) + } +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1340e1b06515..85e661b9c6ce 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3236,6 +3236,82 @@ } } }, + "/theme/active": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_active_theme", + "responses": { + "200": { + "description": "Get the currently active theme ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActiveThemeResponse" + } + } + } + } + } + } + }, + "/theme/apply-preset": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "apply_theme_preset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyPresetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Theme preset applied successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Theme preset not found" + }, + "500": { + "description": "Failed to apply theme preset" + } + } + } + }, + "/theme/presets": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_theme_presets", + "responses": { + "200": { + "description": "List of all theme presets (built-in and custom)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThemePresetsResponse" + } + } + } + } + } + } + }, "/theme/save": { "post": { "tags": [ @@ -3269,6 +3345,76 @@ } } }, + "/theme/save-custom": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "save_custom_theme", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveCustomThemeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Custom theme saved successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Failed to save custom theme" + } + } + } + }, + "/theme/saved/{id}": { + "delete": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "delete_custom_theme", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Theme ID to delete", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom theme deleted successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Theme not found" + }, + "500": { + "description": "Failed to delete theme" + } + } + } + }, "/theme/variables": { "get": { "tags": [ @@ -3470,6 +3616,15 @@ "propertyName": "actionType" } }, + "ActiveThemeResponse": { + "type": "object", + "properties": { + "theme_id": { + "type": "string", + "nullable": true + } + } + }, "AddExtensionRequest": { "type": "object", "required": [ @@ -3503,6 +3658,17 @@ } } }, + "ApplyPresetRequest": { + "type": "object", + "required": [ + "preset_id" + ], + "properties": { + "preset_id": { + "type": "string" + } + } + }, "Author": { "type": "object", "properties": { @@ -6234,6 +6400,40 @@ } } }, + "SaveCustomThemeRequest": { + "type": "object", + "required": [ + "id", + "name", + "author", + "description", + "tags", + "colors" + ], + "properties": { + "author": { + "type": "string" + }, + "colors": { + "$ref": "#/components/schemas/ThemeColorsDto" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "SavePromptRequest": { "type": "object", "required": [ @@ -6949,6 +7149,82 @@ } } }, + "ThemeColorsDto": { + "type": "object", + "required": [ + "light", + "dark" + ], + "properties": { + "dark": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "light": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ThemePreset": { + "type": "object", + "required": [ + "id", + "name", + "author", + "description", + "tags", + "colors", + "version" + ], + "properties": { + "author": { + "type": "string" + }, + "colors": { + "$ref": "#/components/schemas/ThemeColorsDto" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_custom": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "version": { + "type": "string" + } + } + }, + "ThemePresetsResponse": { + "type": "object", + "required": [ + "presets" + ], + "properties": { + "presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThemePreset" + } + } + } + }, "ThemeVariablesResponse": { "type": "object", "required": [ @@ -6957,7 +7233,6 @@ "properties": { "variables": { "type": "object", - "description": "MCP-compatible CSS variables with light-dark() format\nThese variables use MCP standard naming (--color-*) and light-dark() format\nfor seamless integration with both the main app and MCP apps.", "additionalProperties": { "type": "string" } diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index d8f6d158d673..af0b44b1e5b4 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/themes": "^3.3.0", "@tanstack/react-form": "^1.28.0", "@types/react-router-dom": "^5.3.3", + "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "compare-versions": "^6.1.1", @@ -41,6 +42,7 @@ "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", "react": "^19.2.4", + "react-colorful": "^5.6.1", "react-dom": "^19.2.4", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", @@ -60,6 +62,7 @@ "tw-animate-css": "^1.4.0", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", + "wcag-contrast": "^3.0.0", "zod": "^3.25.76" }, "devDependencies": { @@ -84,6 +87,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/chroma-js": "^3.1.2", "@types/cors": "^2.8.19", "@types/electron-squirrel-startup": "^1.0.2", "@types/express": "^5.0.6", @@ -6605,6 +6609,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/chroma-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.2.tgz", + "integrity": "sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -8692,6 +8703,12 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -10829,6 +10846,15 @@ "dev": true, "license": "MIT" }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -17510,6 +17536,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -18019,6 +18055,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/relative-luminance": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/relative-luminance/-/relative-luminance-2.0.1.tgz", + "integrity": "sha512-wFuITNthJilFPwkK7gNJcULxXBcfFZvZORsvdvxeOdO44wCeZnuQkf3nFFzOR/dpJNxYsdRZJLsepWbyKhnMww==", + "license": "BSD-2-Clause", + "dependencies": { + "esm": "^3.0.84" + } + }, "node_modules/remark-breaks": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", @@ -20612,6 +20657,15 @@ "node": ">=10.13.0" } }, + "node_modules/wcag-contrast": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/wcag-contrast/-/wcag-contrast-3.0.0.tgz", + "integrity": "sha512-RWbpg/S7FOXDCwqC2oFhN/vh8dHzj0OS6dpyOSDHyQFSmqmR+lAUStV/ziTT1GzDqL9wol+nZQB4vCi5yEak+w==", + "license": "BSD-2-Clause", + "dependencies": { + "relative-luminance": "^2.0.0" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 49d7271f4712..63f640104732 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -54,6 +54,7 @@ "@radix-ui/themes": "^3.3.0", "@tanstack/react-form": "^1.28.0", "@types/react-router-dom": "^5.3.3", + "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "compare-versions": "^6.1.1", @@ -71,6 +72,7 @@ "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", "react": "^19.2.4", + "react-colorful": "^5.6.1", "react-dom": "^19.2.4", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", @@ -90,6 +92,7 @@ "tw-animate-css": "^1.4.0", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", + "wcag-contrast": "^3.0.0", "zod": "^3.25.76" }, "devDependencies": { @@ -114,6 +117,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/chroma-js": "^3.1.2", "@types/cors": "^2.8.19", "@types/electron-squirrel-startup": "^1.0.2", "@types/express": "^5.0.6", diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index ed0ccf96d59a..216657a33140 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, cancelDownload, checkProvider, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteModel, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPricing, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getThemeVariables, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, saveTheme, scanRecipe, scheduleRecipe, searchSessions, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetThemeVariablesData, GetThemeVariablesResponse, GetThemeVariablesResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PricingData, PricingQuery, PricingResponse, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningContent, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, SaveThemeData, SaveThemeErrors, SaveThemeRequest, SaveThemeResponse, SaveThemeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThemeVariablesResponse, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, applyThemePreset, backupConfig, callTool, cancelDownload, checkProvider, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteCustomTheme, deleteModel, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getActiveTheme, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPricing, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getThemePresets, getThemeVariables, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, saveCustomTheme, savePrompt, saveRecipe, saveTheme, scanRecipe, scheduleRecipe, searchSessions, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, ActiveThemeResponse, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, ApplyPresetRequest, ApplyThemePresetData, ApplyThemePresetErrors, ApplyThemePresetResponse, ApplyThemePresetResponses, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteCustomThemeData, DeleteCustomThemeErrors, DeleteCustomThemeResponse, DeleteCustomThemeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetActiveThemeData, GetActiveThemeResponse, GetActiveThemeResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetThemePresetsData, GetThemePresetsResponse, GetThemePresetsResponses, GetThemeVariablesData, GetThemeVariablesResponse, GetThemeVariablesResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PricingData, PricingQuery, PricingResponse, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningContent, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SaveCustomThemeData, SaveCustomThemeErrors, SaveCustomThemeRequest, SaveCustomThemeResponse, SaveCustomThemeResponses, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, SaveThemeData, SaveThemeErrors, SaveThemeRequest, SaveThemeResponse, SaveThemeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThemeColorsDto, ThemePreset, ThemePresetsResponse, ThemeVariablesResponse, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 4a4b9c36cc64..bf37c6426655 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetThemeVariablesData, GetThemeVariablesResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListModelsData, ListModelsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, SaveThemeData, SaveThemeErrors, SaveThemeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, ApplyThemePresetData, ApplyThemePresetErrors, ApplyThemePresetResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteCustomThemeData, DeleteCustomThemeErrors, DeleteCustomThemeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetActiveThemeData, GetActiveThemeResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetThemePresetsData, GetThemePresetsResponses, GetThemeVariablesData, GetThemeVariablesResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListModelsData, ListModelsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveCustomThemeData, SaveCustomThemeErrors, SaveCustomThemeResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, SaveThemeData, SaveThemeErrors, SaveThemeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -508,6 +508,19 @@ export const sendTelemetryEvent = (options } }); +export const getActiveTheme = (options?: Options) => (options?.client ?? client).get({ url: '/theme/active', ...options }); + +export const applyThemePreset = (options: Options) => (options.client ?? client).post({ + url: '/theme/apply-preset', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const getThemePresets = (options?: Options) => (options?.client ?? client).get({ url: '/theme/presets', ...options }); + export const saveTheme = (options: Options) => (options.client ?? client).post({ url: '/theme/save', ...options, @@ -517,6 +530,17 @@ export const saveTheme = (options: Options } }); +export const saveCustomTheme = (options: Options) => (options.client ?? client).post({ + url: '/theme/save-custom', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const deleteCustomTheme = (options: Options) => (options.client ?? client).delete({ url: '/theme/saved/{id}', ...options }); + export const getThemeVariables = (options?: Options) => (options?.client ?? client).get({ url: '/theme/variables', ...options }); /** diff --git a/ui/desktop/src/api/theme-api.ts b/ui/desktop/src/api/theme-api.ts new file mode 100644 index 000000000000..854bf4d9dcad --- /dev/null +++ b/ui/desktop/src/api/theme-api.ts @@ -0,0 +1,67 @@ +/** + * Custom theme API functions + * These will be replaced when the OpenAPI spec is regenerated + */ + +import { client } from './client.gen'; +import type { ThemeColorsDto } from './types.gen'; + +export interface SaveCustomThemeRequest { + id: string; + name: string; + author: string; + description: string; + tags: string[]; + colors: ThemeColorsDto; +} + +export interface SaveCustomThemeResponse { + message: string; +} + +export interface DeleteCustomThemeResponse { + message: string; +} + +export interface ActiveThemeResponse { + theme_id: string | null; +} + +/** + * Save a custom theme preset + */ +export const saveCustomTheme = async ( + request: SaveCustomThemeRequest +): Promise => { + const response = await client.post({ + url: '/theme/save-custom', + body: request, + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.data; +}; + +/** + * Delete a custom theme preset by ID + */ +export const deleteCustomTheme = async (id: string): Promise => { + const response = await client.delete({ + url: `/theme/saved/${id}`, + }); + + return response.data; +}; + +/** + * Get the currently active theme ID + */ +export const getActiveTheme = async (): Promise => { + const response = await client.get({ + url: '/theme/active', + }); + + return response.data; +}; diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 8b6f686fdf99..0caa365f5c54 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -25,6 +25,10 @@ export type ActionRequiredData = { user_data: unknown; }; +export type ActiveThemeResponse = { + theme_id?: string | null; +}; + export type AddExtensionRequest = { config: ExtensionConfig; session_id: string; @@ -36,6 +40,10 @@ export type Annotations = { priority?: number; }; +export type ApplyPresetRequest = { + preset_id: string; +}; + export type Author = { contact?: string | null; metadata?: string | null; @@ -979,6 +987,15 @@ export type RunNowResponse = { session_id: string; }; +export type SaveCustomThemeRequest = { + author: string; + colors: ThemeColorsDto; + description: string; + id: string; + name: string; + tags: Array; +}; + export type SavePromptRequest = { content: string; }; @@ -1199,12 +1216,31 @@ export type TextContent = { text: string; }; +export type ThemeColorsDto = { + dark: { + [key: string]: string; + }; + light: { + [key: string]: string; + }; +}; + +export type ThemePreset = { + author: string; + colors: ThemeColorsDto; + description: string; + id: string; + is_custom?: boolean; + name: string; + tags: Array; + version: string; +}; + +export type ThemePresetsResponse = { + presets: Array; +}; + export type ThemeVariablesResponse = { - /** - * MCP-compatible CSS variables with light-dark() format - * These variables use MCP standard naming (--color-*) and light-dark() format - * for seamless integration with both the main app and MCP apps. - */ variables: { [key: string]: string; }; @@ -3925,6 +3961,65 @@ export type SendTelemetryEventResponses = { 202: unknown; }; +export type GetActiveThemeData = { + body?: never; + path?: never; + query?: never; + url: '/theme/active'; +}; + +export type GetActiveThemeResponses = { + /** + * Get the currently active theme ID + */ + 200: ActiveThemeResponse; +}; + +export type GetActiveThemeResponse = GetActiveThemeResponses[keyof GetActiveThemeResponses]; + +export type ApplyThemePresetData = { + body: ApplyPresetRequest; + path?: never; + query?: never; + url: '/theme/apply-preset'; +}; + +export type ApplyThemePresetErrors = { + /** + * Theme preset not found + */ + 404: unknown; + /** + * Failed to apply theme preset + */ + 500: unknown; +}; + +export type ApplyThemePresetResponses = { + /** + * Theme preset applied successfully + */ + 200: string; +}; + +export type ApplyThemePresetResponse = ApplyThemePresetResponses[keyof ApplyThemePresetResponses]; + +export type GetThemePresetsData = { + body?: never; + path?: never; + query?: never; + url: '/theme/presets'; +}; + +export type GetThemePresetsResponses = { + /** + * List of all theme presets (built-in and custom) + */ + 200: ThemePresetsResponse; +}; + +export type GetThemePresetsResponse = GetThemePresetsResponses[keyof GetThemePresetsResponses]; + export type SaveThemeData = { body: SaveThemeRequest; path?: never; @@ -3948,6 +4043,61 @@ export type SaveThemeResponses = { export type SaveThemeResponse = SaveThemeResponses[keyof SaveThemeResponses]; +export type SaveCustomThemeData = { + body: SaveCustomThemeRequest; + path?: never; + query?: never; + url: '/theme/save-custom'; +}; + +export type SaveCustomThemeErrors = { + /** + * Failed to save custom theme + */ + 500: unknown; +}; + +export type SaveCustomThemeResponses = { + /** + * Custom theme saved successfully + */ + 200: string; +}; + +export type SaveCustomThemeResponse = SaveCustomThemeResponses[keyof SaveCustomThemeResponses]; + +export type DeleteCustomThemeData = { + body?: never; + path: { + /** + * Theme ID to delete + */ + id: string; + }; + query?: never; + url: '/theme/saved/{id}'; +}; + +export type DeleteCustomThemeErrors = { + /** + * Theme not found + */ + 404: unknown; + /** + * Failed to delete theme + */ + 500: unknown; +}; + +export type DeleteCustomThemeResponses = { + /** + * Custom theme deleted successfully + */ + 200: string; +}; + +export type DeleteCustomThemeResponse = DeleteCustomThemeResponses[keyof DeleteCustomThemeResponses]; + export type GetThemeVariablesData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index ec841767a6f0..7faaf895f7ad 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1194,7 +1194,7 @@ export default function ChatInput({ } ${isFocused ? 'border-border-strong hover:border-border-strong' : 'border-border-default hover:border-border-default' - } bg-background-default z-10 rounded-t-2xl`} + } bg-background-primary z-10 rounded-t-2xl`} data-drop-zone="true" onDrop={handleLocalDrop} onDragOver={handleLocalDragOver} diff --git a/ui/desktop/src/components/settings/PromptsSettingsSection.tsx b/ui/desktop/src/components/settings/PromptsSettingsSection.tsx index ae2063d37abe..3022d3d8a242 100644 --- a/ui/desktop/src/components/settings/PromptsSettingsSection.tsx +++ b/ui/desktop/src/components/settings/PromptsSettingsSection.tsx @@ -180,7 +180,7 @@ export default function PromptsSettingsSection() {
Edit: {selectedPrompt} {promptData?.is_customized && ( - + Customized )} @@ -223,7 +223,7 @@ export default function PromptsSettingsSection() {
{hasChanges && ( -
+
You have unsaved changes
)} @@ -235,12 +235,12 @@ export default function PromptsSettingsSection() { return (
- +
- +
- Prompt Editing + Prompt Editing

Customize the prompts that define goose's behavior in different contexts. These prompts use Jinja2 templating syntax. Be careful when modifying template variables, @@ -253,7 +253,7 @@ export default function PromptsSettingsSection() { variant="outline" size="sm" onClick={handleResetAll} - className="flex items-center gap-2 border-yellow-500/50 hover:bg-yellow-500/20" + className="flex items-center gap-2 border-border-info hover:bg-background-info" > Reset All @@ -272,7 +272,7 @@ export default function PromptsSettingsSection() {

{prompt.name}

{prompt.is_customized && ( - + Customized )} 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..1c58a9003ecc --- /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, Info } 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}` as any] = value; + return acc; + }, {} as React.CSSProperties); + + // 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({ variable, 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..268ebe61cfff --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/ThemeSelector/PresetGallery.tsx @@ -0,0 +1,300 @@ +/** + * Theme Preset Gallery + * + * Browse and apply built-in theme presets with one click + */ + +import { useState, useEffect } from 'react'; +import { Button } from '../../../../ui/button'; +import { toast } from 'react-toastify'; +import { ThemePreset } from '../../../../../themes/presets/types'; +import { getThemePresets, applyThemePreset } from '../../../../../api'; +import { getActiveTheme, deleteCustomTheme } from '../../../../../api/theme-api'; +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 } = useTheme(); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [applying, setApplying] = useState(null); + const [deleting, setDeleting] = useState(null); + const [activeThemeId, setActiveThemeId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTag, setSelectedTag] = useState(null); + + useEffect(() => { + loadPresets(); + loadActiveTheme(); + }, []); + + const loadPresets = async () => { + try { + setLoading(true); + const response = await getThemePresets(); + setPresets(response.data?.presets || []); + } catch (error) { + console.error('Failed to load theme presets:', error); + toast.error('Failed to load theme presets'); + } finally { + setLoading(false); + } + }; + + const loadActiveTheme = async () => { + try { + const response = await getActiveTheme(); + setActiveThemeId(response.theme_id); + } catch (error) { + console.error('Failed to load active theme:', error); + } + }; + + const handleApplyPreset = async (presetId: string) => { + try { + setApplying(presetId); + + await applyThemePreset({ + body: { + preset_id: presetId, + }, + }); + + toast.success('Theme applied successfully! Reloading...'); + + // Reload the page to apply changes + setTimeout(() => { + window.location.reload(); + }, 1000); + + onApply?.(); + } catch (error) { + console.error('Failed to apply theme preset:', error); + toast.error('Failed to apply theme'); + setApplying(null); + } + }; + + const handleDeleteTheme = async (themeId: string, themeName: string) => { + if (!confirm(`Are you sure you want to delete "${themeName}"? This cannot be undone.`)) { + return; + } + + try { + setDeleting(themeId); + await deleteCustomTheme(themeId); + toast.success(`Theme "${themeName}" deleted successfully`); + + // Reload presets + await loadPresets(); + } catch (error) { + console.error('Failed to delete theme:', error); + toast.error('Failed to delete theme'); + } finally { + setDeleting(null); + } + }; + + // Get all unique tags + const allTags = Array.from( + new Set(presets.flatMap(preset => preset.tags)) + ).sort(); + + // Filter presets + const filteredPresets = presets.filter(preset => { + const matchesSearch = searchQuery === '' || + preset.name.toLowerCase().includes(searchQuery.toLowerCase()) || + preset.description.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesTag = !selectedTag || preset.tags.includes(selectedTag); + + return matchesSearch && matchesTag; + }); + + if (loading) { + return ( +
+
Loading themes...
+
+ ); + } + + return ( +
+ {/* Filter Tags Only */} +
+
+ + {allTags.map(tag => ( + + ))} +
+
+ + {/* Theme Grid - Full Height */} +
+ {filteredPresets.map(preset => { + const isApplied = preset.id === activeThemeId; + const isCustom = preset.is_custom || preset.tags.includes('custom'); + + return ( +
+ {/* Theme Preview Colors - Top of card */} +
+
+
+
+
+
+ + {/* Spacer to push info to bottom */} +
+ + {/* Bottom-aligned content */} +
+ {/* Theme Info */} +
+

+ {preset.name} +

+

+ {preset.description} +

+

+ by {preset.author} +

+
+ + {/* Tags */} +
+ {preset.tags.map(tag => ( + + {tag} + + ))} +
+ + {/* Action Buttons */} +
+ {/* Apply Button */} + + + + + + {applying === preset.id + ? 'Applying...' + : isApplied + ? 'Currently Applied' + : 'Apply Theme'} + + + + {/* Edit and Delete Buttons (only for custom themes) */} + {isCustom && ( + <> + {/* Edit Button */} + + + + + Edit Theme + + + {/* Delete Button */} + + + + + + {deleting === preset.id ? 'Deleting...' : 'Delete Theme'} + + + + )} +
+
+
+ ); + })} +
+ + {filteredPresets.length === 0 && ( +
+ No themes found matching your search. +
+ )} +
+ ); +} 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..e35e07b50862 --- /dev/null +++ b/ui/desktop/src/components/settings/app/ThemeColorEditor/index.tsx @@ -0,0 +1,424 @@ +/** + * ThemeColorEditor Component + * + * Main component for theme customization with color picking, + * preset themes, and advanced features. + */ + +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 { getThemeVariables, saveTheme, applyThemePreset } from '../../../../api'; +import { saveCustomTheme } from '../../../../api/theme-api'; +import { ThemeColorEditorProps, ThemeColors, ColorMode, COLOR_VARIABLES, ColorVariable } 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'; + +export function ThemeColorEditor({ onClose }: ThemeColorEditorProps) { + const { resolvedTheme } = useTheme(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [themeColors, setThemeColors] = useState({ light: {}, dark: {} }); + 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); + + // Use system theme instead of separate mode state + const activeMode: ColorMode = resolvedTheme; + + // Load current theme variables + useEffect(() => { + loadThemeVariables(); + }, []); + + const loadThemeVariables = async () => { + try { + setLoading(true); + const response = await getThemeVariables(); + + if (response.data?.variables) { + // Parse light-dark() format into separate light and dark values + const light: Record = {}; + const dark: Record = {}; + + Object.entries(response.data.variables).forEach(([key, value]) => { + const match = value.match(/light-dark\((.+?),\s*(.+?)\)/); + if (match) { + const varName = key.replace('--', ''); + light[varName] = match[1].trim(); + dark[varName] = match[2].trim(); + } + }); + + setThemeColors({ light, dark }); + } + } catch (error) { + console.error('Failed to load theme variables:', error); + toast.error('Failed to load theme colors'); + } finally { + setLoading(false); + } + }; + + const handleColorChange = (variableName: string, color: string) => { + setThemeColors(prev => ({ + ...prev, + [activeMode]: { + ...prev[activeMode], + [variableName]: color, + }, + })); + }; + + const handleSave = async () => { + try { + setSaving(true); + + // Validate theme name + if (!themeName.trim()) { + toast.error('Please enter a theme name'); + setSaving(false); + return; + } + + // Use existing ID if editing, otherwise generate new one + const themeId = editingThemeId || `custom-${themeName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`; + + // Convert back to CSS format + const cssLines: string[] = []; + + // Light mode + cssLines.push(':root {'); + Object.entries(themeColors.light).forEach(([key, value]) => { + cssLines.push(` --${key}: ${value};`); + }); + cssLines.push('}'); + cssLines.push(''); + + // Dark mode + cssLines.push('.dark {'); + Object.entries(themeColors.dark).forEach(([key, value]) => { + cssLines.push(` --${key}: ${value};`); + }); + cssLines.push('}'); + + const css = cssLines.join('\n'); + + // Save as custom preset for future use first + await saveCustomTheme({ + id: themeId, + name: themeName.trim(), + author: 'You', + description: themeDescription.trim() || 'Custom theme', + tags: ['custom'], + colors: { + light: themeColors.light, + dark: themeColors.dark, + }, + }); + + // Apply the theme (this will also store the active theme ID on backend) + await applyThemePreset({ + body: { + preset_id: themeId, + }, + }); + + const action = editingThemeId ? 'updated' : 'saved'; + toast.success(`Theme "${themeName}" ${action} and applied successfully!`); + + // Reload the page to apply changes + window.location.reload(); + } catch (error) { + console.error('Failed to save theme:', error); + toast.error('Failed to save theme'); + } finally { + setSaving(false); + } + }; + + const handleReset = async () => { + if (!confirm('Are you sure you want to reset to the default theme? This will remove all customizations.')) { + return; + } + + try { + setSaving(true); + await saveTheme({ body: { css: '' } }); // Empty CSS resets theme + toast.success('Theme reset successfully!'); + window.location.reload(); + } catch (error) { + console.error('Failed to reset theme:', error); + toast.error('Failed to reset theme'); + } finally { + setSaving(false); + } + }; + + const handleEditTheme = (preset: any) => { + // Load the preset's colors into the editor + setThemeColors({ + light: preset.colors.light || {}, + dark: preset.colors.dark || {}, + }); + + // Set the theme metadata + setThemeName(preset.name); + setThemeDescription(preset.description || ''); + setEditingThemeId(preset.id); + + // Switch to customize tab + 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); + + if (loading) { + return ( + + +
+
Loading theme...
+
+
+
+ ); + } + + 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 */} + + setActiveMode(v as ColorMode)} className="flex-1 flex flex-col overflow-hidden"> +
+ + Light Mode + Dark Mode + +
+ + + {/* Split Panel Layout */} +
+ {/* Left Panel: Color Pickers (40%) */} +
+ {/* Custom Theme Info Card */} +
+ {/* Theme Preview Colors */} +
+
+
+
+
+
+ + {/* Theme Name */} +
+ + 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" + /> +
+ + {/* Theme Description (Optional) */} +
+ + 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/components/settings/tunnel/TunnelSection.tsx b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx index 100b45360bde..fc638f616bb4 100644 --- a/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx +++ b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx @@ -129,9 +129,9 @@ export default function TunnelSection() { Remote Access -
- -
+
+ +
Preview feature: Enable remote access to goose from mobile devices using secure tunneling.{' '} {error && ( -
+
{error}
)} @@ -193,8 +193,8 @@ export default function TunnelSection() {
{tunnelInfo.state === 'running' && ( -
-

+

+

URL: {tunnelInfo.url}

@@ -315,7 +315,7 @@ export default function TunnelSection() { href={IOS_APP_STORE_URL} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline" + className="inline-flex items-center gap-2 text-sm text-text-info hover:underline" > Open in App Store diff --git a/ui/desktop/src/components/ui/sheet.tsx b/ui/desktop/src/components/ui/sheet.tsx index f9b4ef13cd01..250892ea871a 100644 --- a/ui/desktop/src/components/ui/sheet.tsx +++ b/ui/desktop/src/components/ui/sheet.tsx @@ -52,7 +52,7 @@ function SheetContent({ code.bg-inline-code { margin-bottom: 0 !important; } +.user-message, +.user-message * { + color: var(--color-text-inverse) !important; +} + .scrollbar-thin { scrollbar-width: thin; } 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..a2643115c6d4 --- /dev/null +++ b/ui/desktop/src/themes/presets/index.ts @@ -0,0 +1,74 @@ +/** + * Theme Presets Registry + * + * Central registry of all built-in theme presets + */ + +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'; + +/** + * All available theme presets + */ +export const themePresets: ThemePreset[] = [ + gooseClassic, + highContrast, + nord, + dracula, + solarized, + monokai, + github, + gruvbox, + tokyoNight, + oneDark, +]; + +/** + * Get a theme preset by ID + */ +export function getThemePreset(id: string): ThemePreset | undefined { + return themePresets.find(preset => preset.id === id); +} + +/** + * Get theme presets by tag + */ +export function getThemePresetsByTag(tag: string): ThemePreset[] { + return themePresets.filter(preset => preset.tags.includes(tag)); +} + +/** + * Search theme presets by name or description + */ +export function searchThemePresets(query: string): ThemePreset[] { + const lowerQuery = query.toLowerCase(); + return themePresets.filter( + preset => + preset.name.toLowerCase().includes(lowerQuery) || + preset.description.toLowerCase().includes(lowerQuery) || + preset.tags.some(tag => tag.toLowerCase().includes(lowerQuery)) + ); +} + +/** + * Get all available tags + */ +export function getAllTags(): string[] { + const tags = new Set(); + themePresets.forEach(preset => { + preset.tags.forEach(tag => tags.add(tag)); + }); + return Array.from(tags).sort(); +} + +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..540322c88f3b --- /dev/null +++ b/ui/desktop/src/themes/presets/types.ts @@ -0,0 +1,19 @@ +/** + * Theme Preset Types + */ + +export interface ThemePreset { + id: string; + name: string; + author: string; + description: string; + tags: string[]; + thumbnail?: string; + colors: { + light: Record; + dark: Record; + }; + version: string; +} + +export type ThemeCategory = 'dark' | 'light' | 'high-contrast' | 'colorful' | 'minimal' | 'retro' | 'modern'; diff --git a/ui/desktop/src/utils/colorUtils.ts b/ui/desktop/src/utils/colorUtils.ts new file mode 100644 index 000000000000..5d043429c27b --- /dev/null +++ b/ui/desktop/src/utils/colorUtils.ts @@ -0,0 +1,347 @@ +/** + * Color Utility Functions + * + * Comprehensive color manipulation utilities for theme customization. + * Uses chroma-js for color operations and conversions. + */ + +import chroma from 'chroma-js'; + +/** + * Parse a color string into a chroma color object + * Supports hex, rgb, rgba, hsl, hsla formats + */ +export function parseColor(color: string): chroma.Color | null { + try { + return chroma(color); + } catch (error) { + console.error('Invalid color:', color, error); + return null; + } +} + +/** + * Check if a color string is valid + */ +export function isValidColor(color: string): boolean { + try { + chroma(color); + return true; + } catch { + return false; + } +} + +/** + * Convert color to hex format + */ +export function toHex(color: string): string { + const parsed = parseColor(color); + return parsed ? parsed.hex() : color; +} + +/** + * Convert color to RGB format + */ +export function toRgb(color: string): string { + const parsed = parseColor(color); + return parsed ? parsed.css() : color; +} + +/** + * Convert color to HSL format + */ +export function toHsl(color: string): string { + const parsed = parseColor(color); + return parsed ? parsed.css('hsl') : color; +} + +/** + * Get RGB components as object + */ +export function getRgbComponents(color: string): { r: number; g: number; b: number } | null { + const parsed = parseColor(color); + if (!parsed) return null; + + const [r, g, b] = parsed.rgb(); + return { r, g, b }; +} + +/** + * Get HSL components as object + */ +export function getHslComponents(color: string): { h: number; s: number; l: number } | null { + const parsed = parseColor(color); + if (!parsed) return null; + + const [h, s, l] = parsed.hsl(); + return { + h: isNaN(h) ? 0 : h, // Handle achromatic colors + s: isNaN(s) ? 0 : s, + l: isNaN(l) ? 0 : l + }; +} + +/** + * Lighten a color by a percentage (0-100) + */ +export function lighten(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + + // Chroma uses 0-1 scale, convert from percentage + return parsed.brighten(amount / 100).hex(); +} + +/** + * Darken a color by a percentage (0-100) + */ +export function darken(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + + // Chroma uses 0-1 scale, convert from percentage + return parsed.darken(amount / 100).hex(); +} + +/** + * Increase saturation by a percentage (0-100) + */ +export function saturate(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + + return parsed.saturate(amount / 100).hex(); +} + +/** + * Decrease saturation by a percentage (0-100) + */ +export function desaturate(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + + return parsed.desaturate(amount / 100).hex(); +} + +/** + * Rotate hue by degrees (0-360) + */ +export function rotateHue(color: string, degrees: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + + const hsl = getHslComponents(color); + if (!hsl) return color; + + const newHue = (hsl.h + degrees) % 360; + return chroma.hsl(newHue, hsl.s, hsl.l).hex(); +} + +/** + * Adjust brightness by percentage (-100 to +100) + * Negative values darken, positive values lighten + */ +export function adjustBrightness(color: string, amount: number): string { + if (amount === 0) return color; + return amount > 0 ? lighten(color, amount) : darken(color, Math.abs(amount)); +} + +/** + * Adjust saturation by percentage (-100 to +100) + * Negative values desaturate, positive values saturate + */ +export function adjustSaturation(color: string, amount: number): string { + if (amount === 0) return color; + return amount > 0 ? saturate(color, amount) : desaturate(color, Math.abs(amount)); +} + +/** + * Get complementary color (opposite on color wheel) + */ +export function getComplementary(color: string): string { + return rotateHue(color, 180); +} + +/** + * Get analogous colors (adjacent on color wheel) + */ +export function getAnalogous(color: string): [string, string, string] { + return [ + rotateHue(color, -30), + color, + rotateHue(color, 30) + ]; +} + +/** + * Get triadic colors (evenly spaced on color wheel) + */ +export function getTriadic(color: string): [string, string, string] { + return [ + color, + rotateHue(color, 120), + rotateHue(color, 240) + ]; +} + +/** + * Get tetradic colors (two complementary pairs) + */ +export function getTetradic(color: string): [string, string, string, string] { + return [ + color, + rotateHue(color, 90), + rotateHue(color, 180), + rotateHue(color, 270) + ]; +} + +/** + * Get split complementary colors + */ +export function getSplitComplementary(color: string): [string, string, string] { + return [ + color, + rotateHue(color, 150), + rotateHue(color, 210) + ]; +} + +/** + * Generate monochromatic palette (same hue, different lightness) + */ +export function getMonochromaticPalette(color: string, count: number = 5): string[] { + const parsed = parseColor(color); + if (!parsed) return [color]; + + const hsl = getHslComponents(color); + if (!hsl) return [color]; + + const palette: string[] = []; + const step = 0.8 / (count - 1); // Range from 0.1 to 0.9 lightness + + for (let i = 0; i < count; i++) { + const lightness = 0.1 + (step * i); + palette.push(chroma.hsl(hsl.h, hsl.s, lightness).hex()); + } + + return palette; +} + +/** + * Mix two colors together + */ +export function mixColors(color1: string, color2: string, ratio: number = 0.5): string { + const parsed1 = parseColor(color1); + const parsed2 = parseColor(color2); + + if (!parsed1 || !parsed2) return color1; + + return chroma.mix(parsed1, parsed2, ratio).hex(); +} + +/** + * Get color luminance (0-1) + */ +export function getLuminance(color: string): number { + const parsed = parseColor(color); + return parsed ? parsed.luminance() : 0; +} + +/** + * Check if color is light (luminance > 0.5) + */ +export function isLight(color: string): boolean { + return getLuminance(color) > 0.5; +} + +/** + * Check if color is dark (luminance <= 0.5) + */ +export function isDark(color: string): boolean { + return !isLight(color); +} + +/** + * Get appropriate text color (black or white) for a background + */ +export function getTextColorForBackground(backgroundColor: string): string { + return isLight(backgroundColor) ? '#000000' : '#ffffff'; +} + +/** + * Generate tints (lighter variations) of a color + */ +export function generateTints(color: string, count: number = 5): string[] { + const tints: string[] = []; + const step = 100 / count; + + for (let i = 0; i < count; i++) { + tints.push(lighten(color, step * i)); + } + + return tints; +} + +/** + * Generate shades (darker variations) of a color + */ +export function generateShades(color: string, count: number = 5): string[] { + const shades: string[] = []; + const step = 100 / count; + + for (let i = 0; i < count; i++) { + shades.push(darken(color, step * i)); + } + + return shades; +} + +/** + * Generate a complete color scale (tints + base + shades) + */ +export function generateColorScale(color: string, steps: number = 9): string[] { + const halfSteps = Math.floor(steps / 2); + const tints = generateTints(color, halfSteps).reverse(); + const shades = generateShades(color, halfSteps).slice(1); + + return [...tints, color, ...shades]; +} + +/** + * Ensure color has minimum contrast with background + * Adjusts lightness until minimum contrast is met + */ +export function ensureContrast( + foreground: string, + background: string, + minContrast: number = 4.5 +): string { + const parsed = parseColor(foreground); + if (!parsed) return foreground; + + let adjusted = foreground; + let iterations = 0; + const maxIterations = 20; + + // Import contrast function (will be defined in contrastUtils) + // For now, we'll use a placeholder + const getContrast = (fg: string, bg: string): number => { + const fgLum = getLuminance(fg); + const bgLum = getLuminance(bg); + const lighter = Math.max(fgLum, bgLum); + const darker = Math.min(fgLum, bgLum); + return (lighter + 0.05) / (darker + 0.05); + }; + + while (getContrast(adjusted, background) < minContrast && iterations < maxIterations) { + // If background is light, darken foreground; if dark, lighten foreground + adjusted = isLight(background) + ? darken(adjusted, 5) + : lighten(adjusted, 5); + iterations++; + } + + return adjusted; +} diff --git a/ui/desktop/src/utils/contrastUtils.ts b/ui/desktop/src/utils/contrastUtils.ts new file mode 100644 index 000000000000..57b4f478c0d0 --- /dev/null +++ b/ui/desktop/src/utils/contrastUtils.ts @@ -0,0 +1,289 @@ +/** + * WCAG Contrast Utilities + * + * Functions for checking color contrast ratios and WCAG compliance. + * Implements WCAG 2.1 Level AA and AAA standards. + */ + +import { getLuminance, parseColor, lighten, darken, isLight } from './colorUtils'; + +export interface ContrastResult { + ratio: number; + meetsAA: boolean; + meetsAAA: boolean; + meetsAALarge: boolean; + meetsAAALarge: boolean; + foreground: string; + background: string; + suggestion?: string; +} + +/** + * Calculate contrast ratio between two colors + * Returns a value between 1 and 21 + * WCAG formula: (L1 + 0.05) / (L2 + 0.05) + * where L1 is the lighter color and L2 is the darker + */ +export function getContrastRatio(foreground: string, background: string): number { + const fgLuminance = getLuminance(foreground); + const bgLuminance = getLuminance(background); + + const lighter = Math.max(fgLuminance, bgLuminance); + const darker = Math.min(fgLuminance, bgLuminance); + + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Check if contrast meets WCAG 2.1 Level AA standards + * Normal text: 4.5:1 + * Large text (18pt+ or 14pt+ bold): 3:1 + */ +export function meetsWCAGAA( + foreground: string, + background: string, + isLargeText: boolean = false +): boolean { + const ratio = getContrastRatio(foreground, background); + return isLargeText ? ratio >= 3 : ratio >= 4.5; +} + +/** + * Check if contrast meets WCAG 2.1 Level AAA standards + * Normal text: 7:1 + * Large text: 4.5:1 + */ +export function meetsWCAGAAA( + foreground: string, + background: string, + isLargeText: boolean = false +): boolean { + const ratio = getContrastRatio(foreground, background); + return isLargeText ? ratio >= 4.5 : ratio >= 7; +} + +/** + * Get comprehensive contrast check result + */ +export function checkContrast( + foreground: string, + background: string +): ContrastResult { + const ratio = getContrastRatio(foreground, background); + + return { + ratio, + meetsAA: ratio >= 4.5, + meetsAAA: ratio >= 7, + meetsAALarge: ratio >= 3, + meetsAAALarge: ratio >= 4.5, + foreground, + background, + }; +} + +/** + * Suggest an accessible color that meets minimum contrast + * Preserves hue while adjusting lightness + */ +export function suggestAccessibleColor( + foreground: string, + background: string, + targetRatio: number = 4.5 +): string { + const parsed = parseColor(foreground); + if (!parsed) return foreground; + + let adjusted = foreground; + let currentRatio = getContrastRatio(adjusted, background); + let iterations = 0; + const maxIterations = 30; + const step = 5; // Percentage to adjust each iteration + + // Determine direction: if background is light, darken foreground; if dark, lighten + const shouldDarken = isLight(background); + + while (currentRatio < targetRatio && iterations < maxIterations) { + adjusted = shouldDarken + ? darken(adjusted, step) + : lighten(adjusted, step); + + currentRatio = getContrastRatio(adjusted, background); + iterations++; + } + + return adjusted; +} + +/** + * Get all contrast results for a color pair with suggestions + */ +export function getContrastWithSuggestion( + foreground: string, + background: string +): ContrastResult { + const result = checkContrast(foreground, background); + + // If it doesn't meet AA, provide a suggestion + if (!result.meetsAA) { + result.suggestion = suggestAccessibleColor(foreground, background, 4.5); + } + + return result; +} + +/** + * Check multiple color pairs and return results + */ +export function checkMultipleContrasts( + pairs: Array<{ foreground: string; background: string; label?: string }> +): Array { + return pairs.map(({ foreground, background, label }) => ({ + ...getContrastWithSuggestion(foreground, background), + label, + })); +} + +/** + * Get WCAG level string for a contrast ratio + */ +export function getWCAGLevel(ratio: number, isLargeText: boolean = false): string { + if (isLargeText) { + if (ratio >= 4.5) return 'AAA'; + if (ratio >= 3) return 'AA'; + return 'Fail'; + } + + if (ratio >= 7) return 'AAA'; + if (ratio >= 4.5) return 'AA'; + return 'Fail'; +} + +/** + * Get a color-coded status for contrast ratio + */ +export function getContrastStatus(ratio: number): 'pass' | 'warning' | 'fail' { + if (ratio >= 4.5) return 'pass'; + if (ratio >= 3) return 'warning'; + return 'fail'; +} + +/** + * Calculate accessibility score for a theme (0-100) + * Based on how many color pairs meet WCAG AA standards + */ +export function calculateAccessibilityScore( + colorPairs: Array<{ foreground: string; background: string }> +): { + score: number; + passing: number; + failing: number; + warnings: number; + details: ContrastResult[]; +} { + const results = colorPairs.map(({ foreground, background }) => + checkContrast(foreground, background) + ); + + const passing = results.filter(r => r.meetsAA).length; + const warnings = results.filter(r => !r.meetsAA && r.meetsAALarge).length; + const failing = results.filter(r => !r.meetsAALarge).length; + + const score = Math.round((passing / results.length) * 100); + + return { + score, + passing, + failing, + warnings, + details: results, + }; +} + +/** + * Get recommended minimum contrast ratios + */ +export const WCAG_STANDARDS = { + AA: { + normal: 4.5, + large: 3, + }, + AAA: { + normal: 7, + large: 4.5, + }, +} as const; + +/** + * Format contrast ratio for display + */ +export function formatContrastRatio(ratio: number): string { + return `${ratio.toFixed(2)}:1`; +} + +/** + * Check if a color combination is safe for UI elements + * Considers both text and interactive element requirements + */ +export function isSafeForUI( + foreground: string, + background: string, + elementType: 'text' | 'interactive' | 'decorative' = 'text' +): boolean { + const ratio = getContrastRatio(foreground, background); + + switch (elementType) { + case 'text': + return ratio >= 4.5; + case 'interactive': + return ratio >= 3; // WCAG 2.1 non-text contrast + case 'decorative': + return true; // No minimum requirement + default: + return ratio >= 4.5; + } +} + +/** + * Batch check all text/background combinations in a theme + */ +export function validateThemeContrast(theme: { + backgrounds: Record; + textColors: Record; +}): { + valid: boolean; + issues: Array<{ + background: string; + text: string; + ratio: number; + suggestion: string; + }>; +} { + const issues: Array<{ + background: string; + text: string; + ratio: number; + suggestion: string; + }> = []; + + // Check each text color against each background + Object.entries(theme.backgrounds).forEach(([bgKey, bgValue]) => { + Object.entries(theme.textColors).forEach(([textKey, textValue]) => { + const result = getContrastWithSuggestion(textValue, bgValue); + + if (!result.meetsAA && result.suggestion) { + issues.push({ + background: bgKey, + text: textKey, + ratio: result.ratio, + suggestion: result.suggestion, + }); + } + }); + }); + + return { + valid: issues.length === 0, + issues, + }; +} diff --git a/ui/desktop/src/utils/themeValidator.ts b/ui/desktop/src/utils/themeValidator.ts new file mode 100644 index 000000000000..f74232e78c7b --- /dev/null +++ b/ui/desktop/src/utils/themeValidator.ts @@ -0,0 +1,310 @@ +/** + * Theme Validator + * + * Validates theme structure and ensures all required colors are present. + */ + +import { isValidColor } from './colorUtils'; +import { validateThemeContrast } from './contrastUtils'; + +export interface ThemeColors { + light: Record; + dark: Record; +} + +export interface ValidationError { + field: string; + message: string; + severity: 'error' | 'warning'; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + warnings: ValidationError[]; +} + +/** + * Required MCP color variable names + */ +export const REQUIRED_COLOR_VARIABLES = [ + // Backgrounds + 'color-background-primary', + 'color-background-secondary', + 'color-background-tertiary', + 'color-background-inverse', + + // Borders + 'color-border-primary', + 'color-border-secondary', + + // Text + 'color-text-primary', + 'color-text-secondary', + 'color-text-inverse', + + // Ring + 'color-ring-primary', +] as const; + +/** + * Optional semantic color variables + */ +export const OPTIONAL_COLOR_VARIABLES = [ + 'color-background-danger', + 'color-background-info', + 'color-border-danger', + 'color-border-info', + 'color-text-danger', + 'color-text-success', + 'color-text-warning', + 'color-text-info', +] as const; + +/** + * Validate theme structure + */ +export function validateTheme(theme: ThemeColors): ValidationResult { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + // Check if light and dark modes exist + if (!theme.light) { + errors.push({ + field: 'light', + message: 'Light mode colors are required', + severity: 'error', + }); + } + + if (!theme.dark) { + errors.push({ + field: 'dark', + message: 'Dark mode colors are required', + severity: 'error', + }); + } + + // Validate light mode + if (theme.light) { + const lightErrors = validateColorSet(theme.light, 'light'); + errors.push(...lightErrors.filter(e => e.severity === 'error')); + warnings.push(...lightErrors.filter(e => e.severity === 'warning')); + } + + // Validate dark mode + if (theme.dark) { + const darkErrors = validateColorSet(theme.dark, 'dark'); + errors.push(...darkErrors.filter(e => e.severity === 'error')); + warnings.push(...darkErrors.filter(e => e.severity === 'warning')); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate a set of colors (light or dark mode) + */ +function validateColorSet( + colors: Record, + mode: 'light' | 'dark' +): ValidationError[] { + const errors: ValidationError[] = []; + + // Check for required variables + REQUIRED_COLOR_VARIABLES.forEach(variable => { + if (!colors[variable]) { + errors.push({ + field: `${mode}.${variable}`, + message: `Required color variable "${variable}" is missing`, + severity: 'error', + }); + } else if (!isValidColor(colors[variable])) { + errors.push({ + field: `${mode}.${variable}`, + message: `Invalid color value for "${variable}": ${colors[variable]}`, + severity: 'error', + }); + } + }); + + // Check optional variables if present + OPTIONAL_COLOR_VARIABLES.forEach(variable => { + if (colors[variable] && !isValidColor(colors[variable])) { + errors.push({ + field: `${mode}.${variable}`, + message: `Invalid color value for "${variable}": ${colors[variable]}`, + severity: 'error', + }); + } + }); + + // Warn about missing optional variables + OPTIONAL_COLOR_VARIABLES.forEach(variable => { + if (!colors[variable]) { + errors.push({ + field: `${mode}.${variable}`, + message: `Optional color variable "${variable}" is missing`, + severity: 'warning', + }); + } + }); + + return errors; +} + +/** + * Validate color pairs for contrast + */ +export function validateColorPairs(theme: ThemeColors): ValidationResult { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + // Extract backgrounds and text colors for validation + const lightBackgrounds: Record = {}; + const lightTextColors: Record = {}; + const darkBackgrounds: Record = {}; + const darkTextColors: Record = {}; + + Object.entries(theme.light).forEach(([key, value]) => { + if (key.startsWith('color-background-')) { + lightBackgrounds[key] = value; + } else if (key.startsWith('color-text-')) { + lightTextColors[key] = value; + } + }); + + Object.entries(theme.dark).forEach(([key, value]) => { + if (key.startsWith('color-background-')) { + darkBackgrounds[key] = value; + } else if (key.startsWith('color-text-')) { + darkTextColors[key] = value; + } + }); + + // Validate light mode contrast + const lightResult = validateThemeContrast({ + backgrounds: lightBackgrounds, + textColors: lightTextColors, + }); + + lightResult.issues.forEach(issue => { + warnings.push({ + field: `light.${issue.background}-${issue.text}`, + message: `Low contrast (${issue.ratio.toFixed(2)}:1) between ${issue.text} and ${issue.background}. Suggested: ${issue.suggestion}`, + severity: 'warning', + }); + }); + + // Validate dark mode contrast + const darkResult = validateThemeContrast({ + backgrounds: darkBackgrounds, + textColors: darkTextColors, + }); + + darkResult.issues.forEach(issue => { + warnings.push({ + field: `dark.${issue.background}-${issue.text}`, + message: `Low contrast (${issue.ratio.toFixed(2)}:1) between ${issue.text} and ${issue.background}. Suggested: ${issue.suggestion}`, + severity: 'warning', + }); + }); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Get overall theme accessibility score + */ +export function getAccessibilityScore(theme: ThemeColors): { + score: number; + grade: 'A' | 'B' | 'C' | 'D' | 'F'; + issues: number; +} { + const validation = validateColorPairs(theme); + const issues = validation.warnings.length; + + // Calculate score based on number of issues + // Assuming ~20 color pair checks, score decreases by 5 per issue + const maxIssues = 20; + const score = Math.max(0, 100 - (issues * 5)); + + let grade: 'A' | 'B' | 'C' | 'D' | 'F'; + if (score >= 90) grade = 'A'; + else if (score >= 80) grade = 'B'; + else if (score >= 70) grade = 'C'; + else if (score >= 60) grade = 'D'; + else grade = 'F'; + + return { + score, + grade, + issues, + }; +} + +/** + * Check if theme is complete (has all required variables) + */ +export function isThemeComplete(theme: ThemeColors): boolean { + const validation = validateTheme(theme); + return validation.valid; +} + +/** + * Get missing required variables + */ +export function getMissingVariables(theme: ThemeColors): { + light: string[]; + dark: string[]; +} { + const missingLight: string[] = []; + const missingDark: string[] = []; + + REQUIRED_COLOR_VARIABLES.forEach(variable => { + if (!theme.light[variable]) { + missingLight.push(variable); + } + if (!theme.dark[variable]) { + missingDark.push(variable); + } + }); + + return { + light: missingLight, + dark: missingDark, + }; +} + +/** + * Sanitize theme by removing invalid colors + */ +export function sanitizeTheme(theme: ThemeColors): ThemeColors { + const sanitized: ThemeColors = { + light: {}, + dark: {}, + }; + + // Filter out invalid colors + Object.entries(theme.light).forEach(([key, value]) => { + if (isValidColor(value)) { + sanitized.light[key] = value; + } + }); + + Object.entries(theme.dark).forEach(([key, value]) => { + if (isValidColor(value)) { + sanitized.dark[key] = value; + } + }); + + return sanitized; +}