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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.13.0] - 2026-05-27

### Added
- **Theme customization** — persistent appearance settings with Dark/Light/Custom modes.
Choose from 12 curated presets (6 dark, 6 light) or fully customize accent, background,
surface, and text colors. Settings saved between sessions.
- **Theme button** — palette icon in top-right corner, accessible from every page.
- **Background shade slider** — fine-tune lightness/darkness within any preset.
- **Auto companion preset** — switching Dark↔Light automatically selects the matching
color family (e.g. Midnight Indigo ↔ Clean Indigo).

### Changed
- All color resources converted from `StaticResource` to `DynamicResource` for live
theme switching without restart.

## [1.12.1] - 2026-05-27

### Fixed
Expand Down
180 changes: 105 additions & 75 deletions SysManager/SysManager/App.xaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions SysManager/SysManager/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ protected override void OnStartup(StartupEventArgs e)
TaskScheduler.UnobservedTaskException += OnTask;
base.OnStartup(e);

ThemeService.Instance.Initialize();

// Start listening for activation requests from subsequent instances
StartPipeListener();
}
Expand Down
92 changes: 58 additions & 34 deletions SysManager/SysManager/MainWindow.xaml

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions SysManager/SysManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,11 @@ protected override void OnClosed(EventArgs e)
(DataContext as MainWindowViewModel)?.Dispose();
base.OnClosed(e);
}

private void ThemeBtn_Click(object sender, MouseButtonEventArgs e)
{
if (ThemePopupHost.Child is null)
ThemePopupHost.Child = new Views.ThemePopup();
ThemePopupHost.IsOpen = !ThemePopupHost.IsOpen;
}
}
310 changes: 310 additions & 0 deletions SysManager/SysManager/Services/ThemeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
// SysManager · ThemeService — runtime theme switching with persistence
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Media;

namespace SysManager.Services;

public sealed class ThemeService
{
private static ThemeService? _instance;
public static ThemeService Instance => _instance ??= new ThemeService();

private static readonly string SettingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SysManager", "theme.json");

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed

public event Action? ThemeChanged;

public ThemePreset CurrentTheme { get; private set; } = ThemePreset.Defaults["midnight-indigo"];
public string CurrentPresetId { get; private set; } = "midnight-indigo";
public string CurrentMode { get; private set; } = "dark";
public double ShadePosition { get; private set; } = 0.5;

private ThemePreset _baseTheme = ThemePreset.Defaults["midnight-indigo"];

private static readonly Dictionary<string, string> DarkToLight = new()
{
["midnight-indigo"] = "clean-indigo",
["deep-ocean"] = "sky-breeze",
["dark-forest"] = "mint-fresh",
["neon-rose"] = "soft-blossom",
["violet-night"] = "lavender",
["warm-ember"] = "warm-sand",
};

private static readonly Dictionary<string, string> LightToDark =
DarkToLight.ToDictionary(kv => kv.Value, kv => kv.Key);

private ThemeService() { }

public void Initialize()
{
Load();
Apply(CurrentTheme);
}

public string GetCompanionPreset(string targetMode)
{
if (targetMode == "dark" && LightToDark.TryGetValue(CurrentPresetId, out var darkId))
return darkId;
if (targetMode == "light" && DarkToLight.TryGetValue(CurrentPresetId, out var lightId))
return lightId;
return targetMode == "dark" ? "midnight-indigo" : "clean-indigo";
}

public void SetPreset(string id)
{
if (!ThemePreset.Defaults.TryGetValue(id, out var preset)) return;
CurrentPresetId = id;
_baseTheme = preset;
CurrentMode = preset.IsDark ? "dark" : "light";
ApplyShade();
Save();
}

public void SetAccent(Color accent)
{
_baseTheme = _baseTheme with { Accent = accent };
ApplyShade();
Save();
}

public void SetShade(double position)
{
ShadePosition = Math.Clamp(position, 0, 1);
ApplyShade();
Save();
}

public void SetCustom(Color accent, Color background, Color surface, Color text)
{
CurrentMode = "custom";
CurrentPresetId = "custom";
_baseTheme = new ThemePreset(
Id: "custom",
Name: "Custom",
IsDark: background.R + background.G + background.B < 384,
Accent: accent,
Background: background,
Surface: surface,
Surface2: Lerp(surface, background, 0.5),
Border: Lerp(surface, text, 0.15),
TextPrimary: text,
TextSecondary: Lerp(text, background, 0.3),
TextMuted: Lerp(text, background, 0.55));
CurrentTheme = new ThemePreset(
Id: "custom",
Name: "Custom",
IsDark: background.R + background.G + background.B < 384,
Accent: accent,
Background: background,
Surface: surface,
Surface2: Lerp(surface, background, 0.5),
Border: Lerp(surface, text, 0.15),
TextPrimary: text,
TextSecondary: Lerp(text, background, 0.3),
TextMuted: Lerp(text, background, 0.55));
Apply(CurrentTheme);
Save();
}

private void ApplyShade()
{
var t = _baseTheme;
var offset = (ShadePosition - 0.5) * 0.12;

CurrentTheme = t with
{
Background = ShiftLightness(t.Background, offset),
Surface = ShiftLightness(t.Surface, offset),
Surface2 = ShiftLightness(t.Surface2, offset),
Border = ShiftLightness(t.Border, offset)
};
Apply(CurrentTheme);
}

public void Apply(ThemePreset theme)
{
var res = Application.Current.Resources;
SetBrush(res, "Surface0", theme.Background);
SetBrush(res, "Surface1", theme.Surface);
SetBrush(res, "Surface2", theme.Surface2);
SetBrush(res, "Surface3", Lerp(theme.Surface2, theme.TextPrimary, 0.05));
SetBrush(res, "Surface4", Lerp(theme.Surface2, theme.TextPrimary, 0.1));
SetBrush(res, "Border1", theme.Border);
SetBrush(res, "Border2", Lerp(theme.Border, theme.TextPrimary, 0.08));
SetBrush(res, "BorderAccent", Lerp(theme.Border, theme.Accent, 0.2));
SetBrush(res, "TextPrimary", theme.TextPrimary);
SetBrush(res, "TextSecondary", theme.TextSecondary);
SetBrush(res, "TextMuted", theme.TextMuted);
SetBrush(res, "TextDisabled", Lerp(theme.TextMuted, theme.Background, 0.4));
SetBrush(res, "Accent", theme.Accent);
SetBrush(res, "AccentHover", Lighten(theme.Accent, 0.15));
SetBrush(res, "AccentPressed", Darken(theme.Accent, 0.12));
SetBrush(res, "AccentSoft", Color.FromArgb(15, theme.Accent.R, theme.Accent.G, theme.Accent.B));

SetColor(res, "Surface0Color", theme.Background);
SetColor(res, "Surface1Color", theme.Surface);
SetColor(res, "Surface2Color", theme.Surface2);
SetColor(res, "Surface3Color", Lerp(theme.Surface2, theme.TextPrimary, 0.05));
SetColor(res, "Surface4Color", Lerp(theme.Surface2, theme.TextPrimary, 0.1));
SetColor(res, "AccentColor", theme.Accent);
SetColor(res, "AccentHoverColor", Lighten(theme.Accent, 0.15));
SetColor(res, "AccentPressedColor", Darken(theme.Accent, 0.12));

ThemeChanged?.Invoke();
}

private static void SetBrush(ResourceDictionary res, string key, Color color)
{
res[key] = new SolidColorBrush(color);
}

private static void SetColor(ResourceDictionary res, string key, Color color)
{
res[key] = color;
}

private static Color Lerp(Color a, Color b, double t)
{
return Color.FromArgb(
(byte)(a.A + (b.A - a.A) * t),
(byte)(a.R + (b.R - a.R) * t),
(byte)(a.G + (b.G - a.G) * t),
(byte)(a.B + (b.B - a.B) * t));
}

private static Color Lighten(Color c, double amount)
{
return Color.FromArgb(c.A,
(byte)Math.Min(255, c.R + (255 - c.R) * amount),
(byte)Math.Min(255, c.G + (255 - c.G) * amount),
(byte)Math.Min(255, c.B + (255 - c.B) * amount));
}

private static Color Darken(Color c, double amount)
{
return Color.FromArgb(c.A,
(byte)(c.R * (1 - amount)),
(byte)(c.G * (1 - amount)),
(byte)(c.B * (1 - amount)));
}

private static Color ShiftLightness(Color c, double amount)
{
if (amount >= 0)
return Lighten(c, amount);
return Darken(c, -amount);
}

private void Save()
{
try
{
var dir = Path.GetDirectoryName(SettingsPath)!;
Directory.CreateDirectory(dir);
var data = new ThemeSettings(CurrentPresetId, CurrentMode, ShadePosition,
CurrentTheme.Accent.ToString(), CurrentTheme.Background.ToString(),
CurrentTheme.Surface.ToString(), CurrentTheme.TextPrimary.ToString());
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(SettingsPath, json);
}
catch { }

Check notice

Code scanning / CodeQL

Poor error handling: empty catch block Note

Poor error handling: empty catch block.

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed
}
Comment on lines +217 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid silent failure on theme persistence/load errors.

Both Save() and Load() swallow all exceptions (Line 217, Line 248). This hides broken persistence and makes support/debugging difficult.

🔧 Suggested fix
-        catch { }
+        catch (Exception ex)
+        {
+            LogService.Logger?.Warning(ex, "Failed to save theme settings to {Path}", SettingsPath);
+        }
...
-        catch { }
+        catch (Exception ex)
+        {
+            LogService.Logger?.Warning(ex, "Failed to load theme settings from {Path}", SettingsPath);
+        }

Also applies to: 248-249

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/ThemeService.cs` around lines 217 - 218, The
empty catch blocks in ThemeService.Save() and ThemeService.Load() are hiding
errors; replace them with a catch(Exception ex) that records the failure (use
the existing logging mechanism in ThemeService, e.g., _logger.LogError(ex,
"ThemeService.Save failed for theme {ThemeName}") or similar) and then rethrow
(or return a failure result) so callers and telemetry see the issue; update both
Save() and Load() to log the exception and not silently swallow it.


private void Load()
{
try
{
if (!File.Exists(SettingsPath)) return;
var json = File.ReadAllText(SettingsPath);
var data = JsonSerializer.Deserialize<ThemeSettings>(json);
if (data is null) return;

CurrentMode = data.Mode;
ShadePosition = data.ShadePosition;
CurrentPresetId = data.PresetId;
Comment on lines +229 to +231

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp persisted shade value before applying it.

ShadePosition is read from disk without bounds checking (Line 230). If the JSON is edited/corrupted, negative or very large values can produce invalid darken/lighten math and unexpected colors.

🔧 Suggested fix
-            ShadePosition = data.ShadePosition;
+            ShadePosition = Math.Clamp(data.ShadePosition, 0d, 1d);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CurrentMode = data.Mode;
ShadePosition = data.ShadePosition;
CurrentPresetId = data.PresetId;
CurrentMode = data.Mode;
ShadePosition = Math.Clamp(data.ShadePosition, 0d, 1d);
CurrentPresetId = data.PresetId;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/ThemeService.cs` around lines 229 - 231, Clamp
the persisted shade value before assigning it to ShadePosition to prevent
out-of-range values from corrupted JSON; replace the direct assignment
"ShadePosition = data.ShadePosition" with a clamped value (e.g., ShadePosition =
Math.Clamp(data.ShadePosition, 0, 100) or a manual min/max clamp if Math.Clamp
isn't available) in the same method where CurrentMode and CurrentPresetId are
set (the block assigning CurrentMode, ShadePosition, CurrentPresetId in
ThemeService/ThemeService.cs).


if (data.PresetId == "custom")
{
var accent = (Color)ColorConverter.ConvertFromString(data.Accent);
var bg = (Color)ColorConverter.ConvertFromString(data.Background);
var surface = (Color)ColorConverter.ConvertFromString(data.Surface);
var text = (Color)ColorConverter.ConvertFromString(data.Text);
SetCustom(accent, bg, surface, text);
}
else if (ThemePreset.Defaults.TryGetValue(data.PresetId, out var preset))
{
_baseTheme = preset;
CurrentTheme = preset;
ApplyShade();
}
}
catch { }

Check notice

Code scanning / CodeQL

Poor error handling: empty catch block Note

Poor error handling: empty catch block.

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed
}

private sealed record ThemeSettings(
string PresetId, string Mode, double ShadePosition,
string Accent, string Background, string Surface, string Text);
}

public sealed record ThemePreset(
string Id,
string Name,
bool IsDark,
Color Accent,
Color Background,
Color Surface,
Color Surface2,
Color Border,
Color TextPrimary,
Color TextSecondary,
Color TextMuted)
{
public static readonly Dictionary<string, ThemePreset> Defaults = new()
{
["midnight-indigo"] = new("midnight-indigo", "Midnight Indigo", true,
C("#6366F1"), C("#070A0F"), C("#0E1218"), C("#151A23"), C("#1F2633"),
C("#F1F3F7"), C("#A3ADBF"), C("#6B7489")),
["deep-ocean"] = new("deep-ocean", "Deep Ocean", true,
C("#3B82F6"), C("#050D1A"), C("#0A1628"), C("#0F1D33"), C("#1A2D4D"),
C("#E2E8F0"), C("#94A3B8"), C("#64748B")),
["dark-forest"] = new("dark-forest", "Dark Forest", true,
C("#10B981"), C("#020F0A"), C("#021A12"), C("#03261A"), C("#0A3D2A"),
C("#D1FAE5"), C("#6EE7B7"), C("#34D399")),
["neon-rose"] = new("neon-rose", "Neon Rose", true,
C("#EC4899"), C("#120508"), C("#1A0A0F"), C("#240E16"), C("#3D1525"),
C("#FDF2F8"), C("#F9A8D4"), C("#F472B6")),
["violet-night"] = new("violet-night", "Violet Night", true,
C("#A855F7"), C("#0A0515"), C("#0F0A1A"), C("#160F26"), C("#2D1B4E"),
C("#F3E8FF"), C("#C4B5FD"), C("#8B5CF6")),
["warm-ember"] = new("warm-ember", "Warm Ember", true,
C("#F59E0B"), C("#0F0A04"), C("#1A1008"), C("#24180C"), C("#3D2A12"),
C("#FEF3C7"), C("#FCD34D"), C("#FBBF24")),
["clean-indigo"] = new("clean-indigo", "Clean Indigo", false,
C("#6366F1"), C("#FFFFFF"), C("#F8FAFC"), C("#F1F5F9"), C("#E2E8F0"),
C("#1E1B4B"), C("#4338CA"), C("#6366F1")),
["sky-breeze"] = new("sky-breeze", "Sky Breeze", false,
C("#0EA5E9"), C("#F8FAFC"), C("#F0F9FF"), C("#E0F2FE"), C("#BAE6FD"),
C("#0C4A6E"), C("#0369A1"), C("#0284C7")),
["warm-sand"] = new("warm-sand", "Warm Sand", false,
C("#D97706"), C("#FFFBEB"), C("#FEF3C7"), C("#FDE68A"), C("#FCD34D"),
C("#451A03"), C("#78350F"), C("#92400E")),
["mint-fresh"] = new("mint-fresh", "Mint Fresh", false,
C("#16A34A"), C("#F0FDF4"), C("#DCFCE7"), C("#BBF7D0"), C("#86EFAC"),
C("#14532D"), C("#166534"), C("#15803D")),
["soft-blossom"] = new("soft-blossom", "Soft Blossom", false,
C("#DB2777"), C("#FDF2F8"), C("#FCE7F3"), C("#FBCFE8"), C("#F9A8D4"),
C("#500724"), C("#831843"), C("#9D174D")),
["lavender"] = new("lavender", "Lavender", false,
C("#7C3AED"), C("#FAF5FF"), C("#F3E8FF"), C("#E9D5FF"), C("#D8B4FE"),
C("#2E1065"), C("#4C1D95"), C("#5B21B6")),
};

private static Color C(string hex) => (Color)ColorConverter.ConvertFromString(hex);
}
Loading
Loading