feat: add custom theme support with color picker UI
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 55s
CI / Lint & Test (pull_request) Failing after 5m43s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped

- Add Custom variant to Theme enum and CustomThemeColors struct in Rust config
- Add custom_theme_colors field to HikariConfig for storing user-defined colors
- Create color picker UI in ConfigSidebar with 8 customizable color variables
- Implement live preview when editing custom theme colors
- Apply custom colors on app startup when custom theme is selected
- Use dark theme as base, override with user's custom CSS variables
This commit is contained in:
2026-01-25 20:50:11 -08:00
committed by Naomi Carrigan
parent 73d66e9ae4
commit c45414b0aa
5 changed files with 337 additions and 13 deletions
+31
View File
@@ -92,6 +92,10 @@ pub struct HikariConfig {
#[serde(default)]
pub profile_bio: Option<String>,
// Custom theme colors
#[serde(default)]
pub custom_theme_colors: CustomThemeColors,
}
impl Default for HikariConfig {
@@ -118,6 +122,7 @@ impl Default for HikariConfig {
profile_name: None,
profile_avatar_path: None,
profile_bio: None,
custom_theme_colors: CustomThemeColors::default(),
}
}
}
@@ -150,6 +155,27 @@ pub enum Theme {
Light,
#[serde(rename = "high-contrast")]
HighContrast,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct CustomThemeColors {
#[serde(default)]
pub bg_primary: Option<String>,
#[serde(default)]
pub bg_secondary: Option<String>,
#[serde(default)]
pub bg_terminal: Option<String>,
#[serde(default)]
pub accent_primary: Option<String>,
#[serde(default)]
pub accent_secondary: Option<String>,
#[serde(default)]
pub text_primary: Option<String>,
#[serde(default)]
pub text_secondary: Option<String>,
#[serde(default)]
pub border_color: Option<String>,
}
#[cfg(test)]
@@ -178,6 +204,7 @@ mod tests {
assert!(config.profile_name.is_none());
assert!(config.profile_avatar_path.is_none());
assert!(config.profile_bio.is_none());
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
}
#[test]
@@ -204,6 +231,7 @@ mod tests {
profile_name: Some("Test User".to_string()),
profile_avatar_path: None,
profile_bio: Some("A test bio".to_string()),
custom_theme_colors: CustomThemeColors::default(),
};
let json = serde_json::to_string(&config).unwrap();
@@ -232,5 +260,8 @@ mod tests {
serde_json::to_string(&high_contrast).unwrap(),
"\"high-contrast\""
);
let custom = Theme::Custom;
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
}
}
+212 -6
View File
@@ -3,7 +3,9 @@
configStore,
type HikariConfig,
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
@@ -33,8 +35,20 @@
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
});
let showCustomThemeEditor = $state(false);
let isOpen = $state(false);
let isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -91,9 +105,33 @@
async function handleThemeChange(theme: Theme) {
config.theme = theme;
await configStore.setTheme(theme);
showCustomThemeEditor = theme === "custom";
await configStore.setTheme(theme, config.custom_theme_colors);
}
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
config.custom_theme_colors = {
...config.custom_theme_colors,
[key]: value || null,
};
// Live preview
if (config.theme === "custom") {
applyCustomThemeColors(config.custom_theme_colors);
}
}
// Default dark theme colors for color picker defaults
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f1a",
accent_primary: "#e94560",
accent_secondary: "#ff6b9d",
text_primary: "#ffffff",
text_secondary: "#a0a0a0",
border_color: "#2a2a4a",
};
function toggleTool(tool: string) {
if (config.auto_granted_tools.includes(tool)) {
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
@@ -421,10 +459,10 @@
<!-- Theme Selection -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'dark'
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme === 'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
@@ -432,7 +470,7 @@
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'light'
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme === 'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
@@ -440,17 +478,150 @@
</button>
<button
onclick={() => handleThemeChange("high-contrast")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme ===
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'high-contrast'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="High contrast mode for improved accessibility"
>
High Contrast
Contrast
</button>
<button
onclick={() => handleThemeChange("custom")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme === 'custom'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Create your own custom theme"
>
Custom
</button>
</div>
</div>
<!-- Custom Theme Editor -->
{#if config.theme === "custom" || showCustomThemeEditor}
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
<div class="grid grid-cols-2 gap-3">
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Background</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Secondary BG</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Terminal BG</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Border</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Accent Primary</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Accent Secondary</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.accent_secondary || defaultDarkColors.accent_secondary}
oninput={(e) => handleCustomColorChange("accent_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_secondary || defaultDarkColors.accent_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Text Primary</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]">Text Secondary</label>
<div class="flex gap-2 items-center">
<input
type="color"
value={config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
</span>
</div>
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-3">
Changes preview live. Click Save Settings to persist.
</p>
</div>
{/if}
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
@@ -664,4 +835,39 @@
background: var(--text-tertiary);
cursor: not-allowed;
}
/* Color picker styling */
.color-input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.color-picker {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
background: transparent;
}
.color-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-picker::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker::-moz-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker:hover {
border-color: var(--accent-primary);
}
</style>
+10
View File
@@ -63,6 +63,16 @@
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
});
let streamerModeActive = $state(false);
+83 -6
View File
@@ -1,7 +1,18 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light" | "high-contrast";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export interface CustomThemeColors {
bg_primary: string | null;
bg_secondary: string | null;
bg_terminal: string | null;
accent_primary: string | null;
accent_secondary: string | null;
text_primary: string | null;
text_secondary: string | null;
border_color: string | null;
}
export interface HikariConfig {
model: string | null;
@@ -25,6 +36,7 @@ export interface HikariConfig {
profile_name: string | null;
profile_avatar_path: string | null;
profile_bio: string | null;
custom_theme_colors: CustomThemeColors;
}
const defaultConfig: HikariConfig = {
@@ -49,6 +61,16 @@ const defaultConfig: HikariConfig = {
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
};
function createConfigStore() {
@@ -104,9 +126,24 @@ function createConfigStore() {
closeSidebar: () => isSidebarOpen.set(false),
toggleSidebar: () => isSidebarOpen.update((open) => !open),
setTheme: async (theme: Theme) => {
await updateConfig({ theme });
applyTheme(theme);
setTheme: async (theme: Theme, customColors?: CustomThemeColors) => {
const updates: Partial<HikariConfig> = { theme };
if (customColors) {
updates.custom_theme_colors = customColors;
}
await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
applyTheme(theme, currentConfig.custom_theme_colors);
},
setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors);
}
},
setFontSize: async (size: number) => {
@@ -176,12 +213,52 @@ function createConfigStore() {
};
}
export function applyTheme(theme: Theme) {
export function applyTheme(theme: Theme, customColors?: CustomThemeColors) {
if (typeof document !== "undefined") {
document.documentElement.setAttribute("data-theme", theme);
// For custom theme, we use dark as the base and override with custom colors
document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme);
// Clear any previously applied custom colors
clearCustomThemeColors();
// Apply custom colors if theme is custom
if (theme === "custom" && customColors) {
applyCustomThemeColors(customColors);
}
}
}
export function applyCustomThemeColors(colors: CustomThemeColors) {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary);
if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary);
if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal);
if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary);
if (colors.accent_secondary) root.style.setProperty("--accent-secondary", colors.accent_secondary);
if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary);
if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary);
if (colors.border_color) root.style.setProperty("--border-color", colors.border_color);
}
export function clearCustomThemeColors() {
if (typeof document === "undefined") return;
const root = document.documentElement;
const customProperties = [
"--bg-primary",
"--bg-secondary",
"--bg-terminal",
"--accent-primary",
"--accent-secondary",
"--text-primary",
"--text-secondary",
"--border-color",
];
customProperties.forEach((prop) => root.style.removeProperty(prop));
}
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
+1 -1
View File
@@ -255,7 +255,7 @@
// Apply saved settings on startup
const config = configStore.getConfig();
applyTheme(config.theme);
applyTheme(config.theme, config.custom_theme_colors);
applyFontSize(config.font_size);
// Apply always-on-top setting