Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions docs/architecture/theme-token-optimization.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions scripts/theme-color-governance-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"max": 0
},
"colorScopes.token.uniqueColors": {
"max": 200
"max": 197
},
"colorScopes.exception.uniqueColors": {
"max": 182
Expand Down Expand Up @@ -153,13 +153,13 @@
"max": 0
},
"colorDomainNearPairs.nearTotal": {
"max": 13
"max": 9
},
"colorDomainNearPairs.themePreset.indistinguishableTotal": {
"max": 0
},
"colorDomainNearPairs.themePreset.nearTotal": {
"max": 9
"max": 5
},
"colorDomainNearPairs.themeRuntime.indistinguishableTotal": {
"max": 0
Expand Down Expand Up @@ -240,10 +240,10 @@
"max": 0
},
"colorDomainScopes.themePreset.occurrences": {
"max": 171
"max": 170
},
"colorDomainScopes.themePreset.uniqueColors": {
"max": 119
"max": 116
},
"colorDomainScopes.themeRuntime.occurrences": {
"max": 27
Expand Down
52 changes: 8 additions & 44 deletions scripts/theme-color-near-pair-decisions.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,8 @@
"key": "#1c1c1f <-> #212019",
"decision": "keep",
"owner": "src/web-ui/src/infrastructure/theme/presets",
"reason": "China Night secondary background keeps a warmer ink tint next to the shared dark neutral; it is a theme-identity surface, not a stray duplicate.",
"reevaluateWhen": "Only if China Night background surfaces are redesigned around the shared neutral scale."
},
{
"root": "src/web-ui/src",
"domain": "themePreset",
"key": "#e0e3e8 <-> #e8e8e8",
"decision": "keep",
"owner": "src/web-ui/src/infrastructure/theme/presets",
"reason": "Light quaternary surface and slate button text sit near primary text/background stops; they can appear as adjacent surface/text roles and should not be collapsed without focused screenshots.",
"reevaluateWhen": "Only after button text and light surface-ramp screenshots show no loss of hierarchy."
},
{
"root": "src/web-ui/src",
"domain": "themePreset",
"key": "#e8e8e8 <-> #ebe9e3",
"decision": "keep",
"owner": "src/web-ui/src/infrastructure/theme/presets",
"reason": "China Style elevated paper keeps a warm paper tint while #e8e8e8 is a shared cool neutral/text stop. The distinction is a theme personality cue.",
"reevaluateWhen": "Only if China Style paper surfaces are intentionally moved to the shared neutral ramp."
"reason": "China Night secondary background also feeds Monaco lineHighlight through the theme projection. Merging it into the primary background removes the visible current-line cue and flattens adjacent ink surfaces.",
"reevaluateWhen": "Only if China Night gets a separate editor lineHighlight token or visual review proves the current-line cue remains visible."
},
{
"root": "src/web-ui/src",
Expand Down Expand Up @@ -65,24 +47,6 @@
"reason": "China Style paper uses a warm first-paint surface while #f3f3f5 is a cool light neutral and button state stop. This is cross-theme identity, not a duplicate role.",
"reevaluateWhen": "Only after China Style paper identity is intentionally neutralized."
},
{
"root": "src/web-ui/src",
"domain": "themePreset",
"key": "#334155 <-> #363b54",
"decision": "keep",
"owner": "src/web-ui/src/infrastructure/theme/presets",
"reason": "Light strong text and Tokyo border encode different theme-local roles. They are not the same semantic token even though their RGB distance is low.",
"reevaluateWhen": "Only if both roles are remapped through a shared semantic border/text projection."
},
{
"root": "src/web-ui/src",
"domain": "themePreset",
"key": "#ebe9e3 <-> #f0ede0",
"decision": "keep",
"owner": "src/web-ui/src/infrastructure/theme/presets",
"reason": "China Style tertiary and elevated paper surfaces are adjacent paper-ramp stops. Collapsing them would reduce section/elevation separation.",
"reevaluateWhen": "Only after China Style paper-ramp screenshots prove the two stops are not adjacent in product UI."
},
{
"root": "src/web-ui/src",
"domain": "themeRuntime",
Expand Down Expand Up @@ -125,26 +89,26 @@
"key": "#0e0e10 <-> #121214",
"decision": "keep",
"owner": "BitFun-Installer/src/theme/installerThemesData.ts",
"reason": "Cyber and default dark installer cards keep distinct theme preview backgrounds; the values appear as separate theme cards rather than adjacent layers inside one installer surface.",
"reevaluateWhen": "Only after installer theme preview screenshots show Cyber and default dark cards no longer need separate preview identity."
"reason": "ThemeSetup renders dark theme cards side by side with primary and secondary preview backgrounds. Cyber needs its own darker primary preview seed so the named theme is not distinguishable only by label and accent.",
"reevaluateWhen": "Only if installer theme selection adds a separate identity swatch or screenshots prove the primary preview no longer carries theme identity."
},
{
"root": "BitFun-Installer/src",
"domain": "themePreset",
"key": "#1a1b26 <-> #1a1c1e",
"decision": "keep",
"owner": "BitFun-Installer/src/theme/installerThemesData.ts",
"reason": "Tokyo Night primary background intentionally remains close to the web Tokyo hue while the shared installer card surface stays neutral for preview contrast.",
"reevaluateWhen": "Only if the installer Tokyo Night preview is redesigned to inherit the shared dark card surface."
"reason": "Tokyo Night appears as an adjacent installer preview card, and its primary background should preserve the Tokyo base hue while the shared installer card surface stays neutral.",
"reevaluateWhen": "Only after installer preview cards are redesigned to use a dedicated non-background theme identity affordance."
},
{
"root": "BitFun-Installer/src",
"domain": "themePreset",
"key": "#121214 <-> #1a1814",
"decision": "keep",
"owner": "BitFun-Installer/src/theme/installerThemesData.ts",
"reason": "Ink Night keeps a warm dark preview background near the default dark installer card background; this communicates theme identity across separate theme cards.",
"reevaluateWhen": "Only after installer preview screenshots prove the warm Ink Night identity is no longer visually meaningful."
"reason": "Ink Night uses a warmer dark primary background in adjacent installer preview cards. Collapsing it into the canonical dark seed makes multiple dark choices visually flatter before the app is installed.",
"reevaluateWhen": "Only if visual review shows installer dark theme cards no longer rely on primary background identity."
}
]
}
21 changes: 16 additions & 5 deletions src/apps/desktop/src/api/config_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ pub struct ResetConfigRequest {
pub path: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportConfigRequest {
pub config_data: Value,
}

#[derive(Debug, Deserialize, Default)]
pub struct GetRuntimeLoggingInfoRequest {}

Expand Down Expand Up @@ -262,13 +268,18 @@ pub async fn export_config(state: State<'_, AppState>) -> Result<Value, String>
}

#[tauri::command]
pub async fn import_config(state: State<'_, AppState>, config: Value) -> Result<Value, String> {
pub async fn import_config(
state: State<'_, AppState>,
request: ImportConfigRequest,
) -> Result<Value, String> {
let config_service = &state.config_service;
let config_data = request
.config_data
.get("config")
.cloned()
.unwrap_or(request.config_data);

let export_data: bitfun_core::service::config::ConfigExport =
serde_json::from_value(config).map_err(|e| format!("Invalid config format: {}", e))?;

match config_service.import_config(export_data).await {
match config_service.import_config_data(config_data).await {
Ok(result) => {
state.ai_client_factory.invalidate_cache();
info!("Config imported, AI client cache invalidated");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
"textMuted": "#787c99",
"accent500": "#7aa2f7",
"accent600": "#6183bb",
"borderBase": "rgba(54, 59, 84, 0.6)",
"borderBase": "rgba(51, 65, 85, 0.6)",
"elementBase": "rgba(122, 162, 247, 0.11)",
"radiusBase": "6px",
"spacing4": "16px",
Expand Down
3 changes: 2 additions & 1 deletion src/crates/assembly/core/src/service/config/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,10 @@ impl GlobalConfigManager {
pub async fn update_theme(&self, theme_id: &str) -> BitFunResult<()> {
let service = Self::get_service().await?;
service.set_config("theme.id", theme_id).await?;
let stored_theme_id: String = service.get_config(Some("themes.current")).await?;

Self::broadcast_update(ConfigUpdateEvent::ThemeUpdated {
theme_id: theme_id.to_string(),
theme_id: stored_theme_id,
})
.await;

Expand Down
126 changes: 122 additions & 4 deletions src/crates/assembly/core/src/service/config/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,68 @@ fn canonical_config_path(path: &str) -> &str {
match path {
"ai.review_teams.rate_limit_status" => "ai.review_team_rate_limit_status",
"ai.review_teams.project_strategy_overrides" => "ai.review_team_project_strategy_overrides",
"theme.id" => "themes.current",
_ => path,
}
}

fn normalize_legacy_theme_id(theme_id: &str) -> String {
match theme_id.trim() {
"dark" => "bitfun-dark".to_string(),
"light" => "bitfun-light".to_string(),
normalized => normalized.to_string(),
}
}

fn normalize_legacy_theme_value(value: Value) -> Value {
match value {
Value::String(theme_id) => Value::String(normalize_legacy_theme_id(&theme_id)),
value => value,
}
}

pub(crate) fn normalize_legacy_theme_config_value(mut config: Value) -> Value {
let legacy_theme_id = config
.get("theme")
.and_then(|theme| theme.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|theme_id| !theme_id.is_empty())
.map(normalize_legacy_theme_id);

let Some(config_object) = config.as_object_mut() else {
return config;
};

config_object.remove("theme");

let Some(legacy_theme_id) = legacy_theme_id else {
return config;
};

let themes_value = config_object
.entry("themes".to_string())
.or_insert_with(|| serde_json::json!({}));

if !themes_value.is_object() {
*themes_value = serde_json::json!({});
}

if let Some(themes_object) = themes_value.as_object_mut() {
let has_current = themes_object
.get("current")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|current| !current.is_empty());

if !has_current {
themes_object.insert("current".to_string(), Value::String(legacy_theme_id));
}
}

config
}

/// Configuration manager.
pub struct ConfigManager {
config_dir: PathBuf,
Expand Down Expand Up @@ -122,6 +180,9 @@ impl ConfigManager {
let mut config_value: Value = serde_json::from_str(&content).map_err(|e| {
BitFunError::config(format!("Failed to parse config file as JSON: {}", e))
})?;
let normalized_config_value = normalize_legacy_theme_config_value(config_value.clone());
let legacy_theme_normalized = normalized_config_value != config_value;
config_value = normalized_config_value;

let file_version = config_value
.get("version")
Expand Down Expand Up @@ -157,10 +218,10 @@ impl ConfigManager {

self.config = config;

if needs_migration {
if needs_migration || legacy_theme_normalized {
self.config.version = current_version;
self.save_config().await?;
info!("Config migrated and saved");
info!("Config normalized and saved");
} else {
debug!("Loaded config from file");
}
Expand All @@ -180,6 +241,7 @@ impl ConfigManager {

/// Performs a smart merge from a JSON value.
async fn smart_merge_config_from_value(&mut self, user_value: Value) -> BitFunResult<()> {
let user_value = normalize_legacy_theme_config_value(user_value);
let base_config = self.providers.get_default_config();

let base_value = serde_json::to_value(&base_config).map_err(|e| {
Expand Down Expand Up @@ -312,10 +374,14 @@ impl ConfigManager {
T: serde::Serialize,
{
let old_config = self.config.clone();
let json_value = serde_json::to_value(value)
let mut json_value = serde_json::to_value(value)
.map_err(|e| BitFunError::config(format!("Failed to serialize config value: {}", e)))?;

let original_path = path;
let path = canonical_config_path(path);
if original_path == "theme.id" {
json_value = normalize_legacy_theme_value(json_value);
}
self.set_value_by_path(path, json_value)?;
self.config.last_modified = chrono::Utc::now();

Expand All @@ -336,6 +402,7 @@ impl ConfigManager {
let old_config = self.config.clone();

if let Some(path) = path {
let path = canonical_config_path(path);
let default_config = self.providers.get_default_config();
let default_value = self.get_value_by_path_from_config(&default_config, path)?;
self.set_value_by_path(path, default_value)?;
Expand All @@ -346,6 +413,7 @@ impl ConfigManager {
self.config.last_modified = chrono::Utc::now();

if let Some(path) = path {
let path = canonical_config_path(path);
self.notify_config_changed(path, &old_config).await?;
} else {
for provider_name in self.providers.get_provider_names() {
Expand Down Expand Up @@ -378,6 +446,7 @@ impl ConfigManager {
/// Imports configuration.
pub async fn import_config(&mut self, config_data: serde_json::Value) -> BitFunResult<()> {
let old_config = self.config.clone();
let config_data = normalize_legacy_theme_config_value(config_data);

let imported_config: GlobalConfig = serde_json::from_value(config_data)
.map_err(|e| BitFunError::config(format!("Failed to parse imported config: {}", e)))?;
Expand Down Expand Up @@ -627,7 +696,7 @@ pub struct ConfigStatistics {

#[cfg(test)]
mod tests {
use super::canonical_config_path;
use super::{canonical_config_path, normalize_legacy_theme_config_value};

#[test]
fn canonicalizes_legacy_review_team_auxiliary_paths() {
Expand All @@ -643,6 +712,55 @@ mod tests {
canonical_config_path("ai.review_teams.default"),
"ai.review_teams.default"
);
assert_eq!(canonical_config_path("theme.id"), "themes.current");
}

#[test]
fn legacy_theme_id_moves_to_themes_current_when_missing() {
let normalized = normalize_legacy_theme_config_value(serde_json::json!({
"theme": {
"id": "dark",
"colors": {
"background": "#1e1e1e"
}
}
}));

assert_eq!(normalized["themes"]["current"], "bitfun-dark");
assert!(
normalized.get("theme").is_none(),
"legacy GUI theme payload should not survive normalization"
);
}

#[test]
fn legacy_theme_id_does_not_override_existing_theme_selection() {
let normalized = normalize_legacy_theme_config_value(serde_json::json!({
"theme": {
"id": "bitfun-dark"
},
"themes": {
"current": "bitfun-cyber"
}
}));

assert_eq!(normalized["themes"]["current"], "bitfun-cyber");
assert!(normalized.get("theme").is_none());
}

#[test]
fn legacy_theme_id_fills_empty_existing_theme_selection() {
let normalized = normalize_legacy_theme_config_value(serde_json::json!({
"theme": {
"id": "light"
},
"themes": {
"current": ""
}
}));

assert_eq!(normalized["themes"]["current"], "bitfun-light");
assert!(normalized.get("theme").is_none());
}
}

Expand Down
Loading
Loading