feat: introduced a token-based theming system across the UI

* feat: added themes system

* feat: smart sidebar auto-hide for files/diff tabs + lower files sidebar threshold

* feat: added Checkbox component and update theming

- Add reusable Checkbox component for toggles across UI
- Replace several inputs with Checkbox in settings and commands panels
- Add DiffIcon and apply surface/border theming to key UI areas

* feat: Add convert-vscode-theme.cjs to convert VS Code themes to OpenChamber format

* refactor: remove unused permission logic from ChatInput

- Remove unused permission rules parsing logic from ChatInput
- Memoize renderTheme in DiffWorkerProvider to avoid unnecessary recalculations
- Remove forceOpaque helper in vscode theme adapter

* fix: guard VSCode theme loading in MarkdownRenderer

* feat: add custom user themes loading and reload

- Load user themes from ~/.config/openchamber/themes at runtime
- Expose /api/config/themes to fetch custom themes
- Allow theme reloading from Settings → Theme → Reload themes in the UI
This commit is contained in:
Bohdan Triapitsyn
2026-02-01 18:29:34 +02:00
committed by GitHub
parent 5bb2c56bc0
commit ddedc02687
147 changed files with 9853 additions and 2435 deletions
+43 -6
View File
@@ -26,6 +26,17 @@
document.documentElement.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
// Store for use in inline styles
window.__INITIAL_THEME_DARK__ = isDark;
// Splash colors persisted by the app theme system
var splashBgLight = localStorage.getItem('splashBgLight');
var splashFgLight = localStorage.getItem('splashFgLight');
var splashBgDark = localStorage.getItem('splashBgDark');
var splashFgDark = localStorage.getItem('splashFgDark');
if (splashBgLight) document.documentElement.style.setProperty('--splash-background-light', splashBgLight);
if (splashFgLight) document.documentElement.style.setProperty('--splash-stroke-light', splashFgLight);
if (splashBgDark) document.documentElement.style.setProperty('--splash-background-dark', splashBgDark);
if (splashFgDark) document.documentElement.style.setProperty('--splash-stroke-dark', splashFgDark);
})();
</script>
<style>
@@ -54,18 +65,44 @@
font-family: 'IBM Plex Mono', monospace;
background-color: transparent;
}
/* Theme-aware colors for loading screen */
html.dark {
--splash-stroke: white;
:root {
--splash-background-dark: #151313;
--splash-stroke-dark: white;
--splash-background-light: #F6F4EF;
--splash-stroke-light: black;
--splash-background: var(--splash-background-dark);
--splash-stroke: var(--splash-stroke-dark);
/* Fallback fills (overridden below when supported) */
--splash-face-fill: rgba(255, 255, 255, 0.15);
--splash-cell-fill: rgba(255, 255, 255, 0.35);
--splash-logo-fill: white;
--splash-logo-fill: var(--splash-stroke);
}
html.light {
--splash-stroke: black;
--splash-background: var(--splash-background-light);
--splash-stroke: var(--splash-stroke-light);
--splash-face-fill: rgba(0, 0, 0, 0.15);
--splash-cell-fill: rgba(0, 0, 0, 0.4);
--splash-logo-fill: black;
--splash-logo-fill: var(--splash-stroke);
}
html.dark {
--splash-background: var(--splash-background-dark);
--splash-stroke: var(--splash-stroke-dark);
--splash-logo-fill: var(--splash-stroke);
}
body {
background-color: var(--splash-background);
}
@supports (color: color-mix(in srgb, white 50%, transparent)) {
:root {
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
}
}
.loading {
display: flex;
@@ -28,6 +28,7 @@ pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsL
.settings()
.update_with(|mut settings| {
migrate_legacy_project_settings(&mut settings);
migrate_legacy_theme_settings(&mut settings);
normalize_project_selection(&mut settings);
(settings, ())
})
@@ -52,6 +53,7 @@ pub async fn save_settings(
.settings()
.update_with(|current| {
let mut merged = merge_persisted_settings(&current, &sanitized_changes);
migrate_legacy_theme_settings(&mut merged);
normalize_project_selection(&mut merged);
(merged, ())
})
@@ -610,6 +612,70 @@ fn migrate_legacy_project_settings(settings: &mut Value) {
}
}
fn migrate_legacy_theme_settings(settings: &mut Value) {
if !settings.is_object() {
*settings = json!({});
}
let obj = settings.as_object_mut().unwrap();
let theme_id = obj
.get("themeId")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string());
let theme_variant = obj
.get("themeVariant")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| *value == "light" || *value == "dark")
.map(|value| value.to_string());
let has_light = obj
.get("lightThemeId")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty());
let has_dark = obj
.get("darkThemeId")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty());
if has_light && has_dark {
return;
}
let default_light = "flexoki-light".to_string();
let default_dark = "flexoki-dark".to_string();
if !has_light {
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
if variant == "light" {
id.clone()
} else {
default_light.clone()
}
} else {
default_light.clone()
};
obj.insert("lightThemeId".to_string(), json!(next));
}
if !has_dark {
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
if variant == "dark" {
id.clone()
} else {
default_dark.clone()
}
} else {
default_dark.clone()
};
obj.insert("darkThemeId".to_string(), json!(next));
}
}
fn normalize_project_selection(settings: &mut Value) {
let Some(obj) = settings.as_object_mut() else {
return;
+240
View File
@@ -92,6 +92,8 @@ const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
const MODELS_METADATA_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
const MODELS_METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
const MAX_THEME_JSON_BYTES: u64 = 512 * 1024;
const CHECK_FOR_UPDATES_EVENT: &str = "openchamber:check-for-updates";
#[cfg(target_os = "macos")]
@@ -2059,6 +2061,14 @@ async fn handle_config_routes(
method: Method,
mut req: Request,
) -> Result<Response, StatusCode> {
if path == "/api/config/themes" && method == Method::GET {
let themes = read_custom_themes_from_disk().await;
return Ok(json_response(
StatusCode::OK,
serde_json::json!({ "themes": themes }),
));
}
if let Some(name) = path.strip_prefix("/api/config/agents/") {
let trimmed = name.trim();
if trimmed.is_empty() {
@@ -2564,6 +2574,7 @@ async fn proxy_to_opencode(
let is_desktop_config_route = origin_path.starts_with("/api/config/agents/")
|| origin_path.starts_with("/api/config/commands/")
|| origin_path.starts_with("/api/config/skills")
|| origin_path == "/api/config/themes"
|| origin_path == "/api/config/reload"
|| is_provider_auth_delete
|| is_provider_source_get;
@@ -2724,3 +2735,232 @@ impl SettingsStore {
Ok(candidate)
}
}
fn openchamber_user_config_root() -> Option<PathBuf> {
let home = dirs::home_dir()?;
Some(home.join(".config").join("openchamber"))
}
fn openchamber_themes_dir() -> Option<PathBuf> {
openchamber_user_config_root().map(|root| root.join("themes"))
}
fn value_non_empty_string(value: &Value) -> Option<String> {
value
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
fn get_nested<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}
fn has_required_theme_fields(theme: &Value) -> bool {
let required_paths: [&[&str]; 46] = [
&["metadata", "id"],
&["metadata", "name"],
&["metadata", "variant"],
&["colors", "primary", "base"],
&["colors", "primary", "foreground"],
&["colors", "surface", "background"],
&["colors", "surface", "foreground"],
&["colors", "surface", "muted"],
&["colors", "surface", "mutedForeground"],
&["colors", "surface", "elevated"],
&["colors", "surface", "elevatedForeground"],
&["colors", "surface", "subtle"],
&["colors", "interactive", "border"],
&["colors", "interactive", "selection"],
&["colors", "interactive", "selectionForeground"],
&["colors", "interactive", "focusRing"],
&["colors", "interactive", "hover"],
&["colors", "status", "error"],
&["colors", "status", "errorForeground"],
&["colors", "status", "errorBackground"],
&["colors", "status", "errorBorder"],
&["colors", "status", "warning"],
&["colors", "status", "warningForeground"],
&["colors", "status", "warningBackground"],
&["colors", "status", "warningBorder"],
&["colors", "status", "success"],
&["colors", "status", "successForeground"],
&["colors", "status", "successBackground"],
&["colors", "status", "successBorder"],
&["colors", "status", "info"],
&["colors", "status", "infoForeground"],
&["colors", "status", "infoBackground"],
&["colors", "status", "infoBorder"],
&["colors", "syntax", "base", "background"],
&["colors", "syntax", "base", "foreground"],
&["colors", "syntax", "base", "keyword"],
&["colors", "syntax", "base", "string"],
&["colors", "syntax", "base", "number"],
&["colors", "syntax", "base", "function"],
&["colors", "syntax", "base", "variable"],
&["colors", "syntax", "base", "type"],
&["colors", "syntax", "base", "comment"],
&["colors", "syntax", "base", "operator"],
&["colors", "syntax", "highlights", "diffAdded"],
&["colors", "syntax", "highlights", "diffRemoved"],
&["colors", "syntax", "highlights", "lineNumber"],
];
for path in required_paths {
let Some(value) = get_nested(theme, path) else {
return false;
};
if value_non_empty_string(value).is_none() {
return false;
}
}
let variant = get_nested(theme, &["metadata", "variant"])
.and_then(value_non_empty_string)
.unwrap_or_default();
if variant != "light" && variant != "dark" {
return false;
}
true
}
fn normalize_theme_json(mut theme: Value) -> Option<Value> {
if !theme.is_object() {
return None;
}
if !has_required_theme_fields(&theme) {
return None;
}
let id = get_nested(&theme, &["metadata", "id"]).and_then(value_non_empty_string)?;
let name = get_nested(&theme, &["metadata", "name"]).and_then(value_non_empty_string)?;
let variant = get_nested(&theme, &["metadata", "variant"]).and_then(value_non_empty_string)?;
// Ensure metadata exists and is an object.
let metadata = theme
.get_mut("metadata")
.and_then(|v| v.as_object_mut())?;
metadata.insert("id".to_string(), Value::String(id.trim().to_string()));
metadata.insert("name".to_string(), Value::String(name.trim().to_string()));
metadata.insert("variant".to_string(), Value::String(variant));
if !metadata
.get("description")
.and_then(|v| v.as_str())
.is_some()
{
metadata.insert("description".to_string(), Value::String("".to_string()));
}
let version_ok = metadata
.get("version")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.is_some();
if !version_ok {
metadata.insert("version".to_string(), Value::String("1.0.0".to_string()));
}
if let Some(tags_value) = metadata.get_mut("tags") {
if let Some(tags) = tags_value.as_array_mut() {
tags.retain(|tag| tag.as_str().map(str::trim).filter(|s| !s.is_empty()).is_some());
} else {
*tags_value = Value::Array(vec![]);
}
} else {
metadata.insert("tags".to_string(), Value::Array(vec![]));
}
Some(theme)
}
async fn read_custom_themes_from_disk() -> Vec<Value> {
let Some(dir) = openchamber_themes_dir() else {
return vec![];
};
let mut results: Vec<Value> = vec![];
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut entries = match fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return vec![],
Err(err) => {
warn!("[desktop:themes] Failed to list themes dir {:?}: {}", dir, err);
return vec![];
}
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let is_json = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false);
if !is_json {
continue;
}
let metadata = match entry.metadata().await {
Ok(v) => v,
Err(_) => continue,
};
if !metadata.is_file() {
continue;
}
if metadata.len() > MAX_THEME_JSON_BYTES {
warn!(
"[desktop:themes] Skip {:?}: too large ({} bytes)",
path,
metadata.len()
);
continue;
}
let bytes = match fs::read(&path).await {
Ok(bytes) => bytes,
Err(err) => {
warn!("[desktop:themes] Failed to read {:?}: {}", path, err);
continue;
}
};
let parsed: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(err) => {
warn!("[desktop:themes] Invalid JSON {:?}: {}", path, err);
continue;
}
};
let normalized = match normalize_theme_json(parsed) {
Some(v) => v,
None => {
warn!("[desktop:themes] Invalid theme JSON {:?}", path);
continue;
}
};
let id = get_nested(&normalized, &["metadata", "id"])
.and_then(value_non_empty_string)
.unwrap_or_default();
if id.is_empty() || seen_ids.contains(&id) {
continue;
}
seen_ids.insert(id);
results.push(normalized);
}
results
}