diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs
index 7e32c9d6..517dd19b 100644
--- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs
+++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/ThemeService.cs
@@ -5,45 +5,37 @@ using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
///
-/// Manages the active DevExpress theme and dark mode state for the WASM client.
+/// Manages the active DevExpress theme for the WASM client.
///
-/// Initialization order:
-/// 1. On first load (before login): InitializeAsync() reads localStorage["signflow.darkMode"].
-/// Falls back to window.matchMedia("prefers-color-scheme: dark") if key is absent.
-/// ThemeName defaults to "blazing-berry" until login completes.
-/// 2. After login: LoadFromServerAsync() syncs preferences from the server cache.
-/// - If server has no saved preferences (first login ever), the system dark mode preference
-/// detected locally is written to the server immediately.
-/// - Otherwise server values overwrite local state and localStorage.
+/// Theme selection is the sole UI control — no separate dark/light toggle.
+/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
+///
+/// Initialization:
+/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
+/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
+/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
///
/// Persistence:
-/// - Dark mode toggle → localStorage written immediately + debounced PUT /api/UserPreferences.
-/// - Theme change → debounced PUT /api/UserPreferences (no localStorage for theme).
-/// - All server writes use the in-memory state — no extra GET before each save.
-///
-/// Error handling:
-/// - InitializeAsync / LoadFromServerAsync errors are logged and rethrown.
-/// - Debounced save errors (ToggleDarkMode, SetTheme) are logged; UI is NOT blocked
-/// because the state change is already applied locally. The error is surfaced via
-/// the SaveError property so the UI layer can show a toast/snackbar if desired.
+/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
+/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
///
public class ThemeService : IAsyncDisposable
{
- // ── Allowed themes ────────────────────────────────────────────────────────
+ // ── Allowed themes (ordered dark → light for range slider) ───────────────
+ // DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
public static readonly IReadOnlyList AvailableThemes =
[
- new("Blazing Berry", "blazing-berry"),
- new("Fluent", "fluent"),
- new("Purple", "purple"),
+ new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
+ new("Blazing Berry", "blazing-berry"), // index 1 — middle
+ new("Purple", "purple"), // index 2 — ☀️
];
- private const string LocalStorageDarkModeKey = "signflow.darkMode";
+ private const string LocalStorageThemeKey = "signflow.theme";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
- private bool _isDarkMode = false;
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
@@ -55,12 +47,12 @@ public class ThemeService : IAsyncDisposable
private CancellationTokenSource? _saveCts;
// ── Change & error notification ───────────────────────────────────────────
- /// Fired whenever theme or dark mode changes. Subscribers call StateHasChanged.
+ /// Fired whenever theme changes. Subscribers call StateHasChanged.
public event Action? OnChange;
///
- /// Set when a background save fails. UI layer can read this to show a toast.
- /// Cleared automatically on the next successful save or on next toggle/theme change.
+ /// Set when a background save fails. UI layer can surface this as a warning.
+ /// Cleared on the next successful save or theme change.
///
public Exception? SaveError { get; private set; }
@@ -74,98 +66,89 @@ public class ThemeService : IAsyncDisposable
// ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName;
- public bool IsDarkMode => _isDarkMode;
+
+ /// True when blazing-dark is active — used for Bootstrap data-bs-theme.
+ public bool IsDarkMode => _themeName == "blazing-dark";
+
+ public string? DisplayName { get; private set; }
///
- /// The CSS href value for the active DevExpress theme.
- /// Example: "_content/DevExpress.Blazor.Themes/blazing-berry.dark.bs5.min.css"
+ /// CSS href for the active DevExpress theme.
+ /// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
///
public string ThemeCssHref =>
- $"_content/DevExpress.Blazor.Themes/{_themeName}{(_isDarkMode ? ".dark" : "")}.bs5.min.css";
+ $"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
- /// Bootstrap 5 data-bs-theme attribute value ("dark" or "light").
- public string BootstrapTheme => _isDarkMode ? "dark" : "light";
+ /// Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.
+ public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ────────────────────────────────────────────────────────
///
- /// Must be called once in OnAfterRenderAsync(firstRender: true) — after JS is available.
- /// Reads dark mode from localStorage, falling back to system (prefers-color-scheme).
- /// Safe to call multiple times; subsequent calls are no-ops.
+ /// Called once in OnAfterRenderAsync(firstRender: true).
+ /// Reads theme from localStorage; falls back to system dark-mode preference.
+ /// Safe to call multiple times — subsequent calls are no-ops.
///
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
- var stored = await _js.InvokeAsync("localStorage.getItem", LocalStorageDarkModeKey);
+ var stored = await _js.InvokeAsync("localStorage.getItem", LocalStorageThemeKey);
- if (stored is not null)
+ if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
{
- _isDarkMode = stored == "true";
+ _themeName = stored;
}
else
{
- // No stored preference — detect system setting
- _isDarkMode = await _js.InvokeAsync(
+ // No stored theme — use system preference to pick dark or light
+ var prefersDark = await _js.InvokeAsync(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
+
+ _themeName = prefersDark ? "blazing-dark" : "blazing-berry";
}
+ await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
///
- /// Called after a successful sender login.
- /// Syncs theme + dark mode from the server.
- /// If no preferences exist yet on the server (first ever login),
- /// the locally-detected system preference is saved to the server.
+ /// Called after successful sender login.
+ /// Syncs theme from the server cache; on first login propagates local theme to server.
///
public async Task LoadFromServerAsync(CancellationToken ct = default)
{
var prefs = await _preferencesService.GetAsync(ct);
+
var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode;
- if (isFirstLogin && _isDarkMode)
+ if (isFirstLogin && _themeName != "blazing-berry")
{
- // First login: propagate local system preference to server
- prefs.IsDarkMode = _isDarkMode;
+ // Propagate locally-detected theme to server on first login
+ prefs.ThemeName = _themeName;
+ prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
- await _js.InvokeVoidAsync("localStorage.setItem",
- LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower());
+ // Persist resolved theme to localStorage
+ await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
+ DisplayName = prefs.DisplayName;
+ await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
- // ── Mutations ─────────────────────────────────────────────────────────────
+ // ── Mutation ──────────────────────────────────────────────────────────────
///
- /// Toggles dark mode, updates localStorage immediately, and schedules a server save.
- /// The state change is applied regardless of whether the server save succeeds.
- /// Check SaveError after OnChange fires to detect background save failures.
- ///
- public async Task ToggleDarkModeAsync()
- {
- _isDarkMode = !_isDarkMode;
- SaveError = null;
-
- await _js.InvokeVoidAsync("localStorage.setItem",
- LocalStorageDarkModeKey, _isDarkMode.ToString().ToLower());
-
- OnChange?.Invoke();
- await ScheduleSaveAsync();
- }
-
- ///
- /// Sets the active theme and schedules a server save.
- /// The state change is applied regardless of whether the server save succeeds.
- /// Check SaveError after OnChange fires to detect background save failures.
+ /// Sets the active theme, applies it immediately, and schedules a server save.
+ /// State change is applied regardless of whether the save succeeds.
///
public async Task SetThemeAsync(string themeName)
{
@@ -175,6 +158,9 @@ public class ThemeService : IAsyncDisposable
_themeName = themeName;
SaveError = null;
+
+ await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
+ await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
await ScheduleSaveAsync();
}
@@ -187,13 +173,24 @@ public class ThemeService : IAsyncDisposable
? prefs.ThemeName
: "blazing-berry";
- _isDarkMode = prefs.IsDarkMode;
+ // Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
+ if (prefs.IsDarkMode && _themeName == "blazing-berry")
+ _themeName = "blazing-dark";
+ }
+
+ private async Task ApplyThemeToDocumentAsync()
+ {
+ try
+ {
+ await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme);
+ }
+ catch (Exception ex)
+ {
+ // JS unavailable during server prerender — link is the SSR fallback.
+ _logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
+ }
}
- ///
- /// Debounced server save. Uses current in-memory state — no extra GET.
- /// Only theme and dark mode are written; GridLayouts are preserved from cache.
- ///
private async Task ScheduleSaveAsync()
{
_saveCts?.Cancel();
@@ -203,22 +200,21 @@ public class ThemeService : IAsyncDisposable
{
await Task.Delay(500, _saveCts.Token);
- // Use cached preferences to preserve GridLayouts — no network GET needed.
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName;
- prefs.IsDarkMode = _isDarkMode;
+ prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
- // A newer save was scheduled — this one is intentionally cancelled.
+ // Newer save scheduled — intentionally cancelled.
}
catch (Exception ex)
{
_logger.LogError(ex, "Background save of user preferences failed.");
SaveError = ex;
- OnChange?.Invoke(); // Notify UI so it can show SaveError
+ OnChange?.Invoke();
}
}
@@ -232,5 +228,5 @@ public class ThemeService : IAsyncDisposable
/// Describes a selectable DevExpress theme.
/// User-facing display name.
-/// CSS theme identifier (used in href and API).
+/// CSS theme identifier.
public sealed record ThemeOption(string Label, string Value);
diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs
index bad20009..daaa34ed 100644
--- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs
+++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/UserPreferencesService.cs
@@ -45,8 +45,9 @@ public class UserPreferencesService(
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
- // 404 means no preferences saved yet — return and cache defaults.
- if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ // Not authenticated yet (login page) or no preferences saved — return defaults.
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
+ response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
SetCache(new UserPreferencesDto());
return _cached!;
@@ -82,6 +83,11 @@ public class UserPreferencesService(
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
+
+ // Not authenticated (login page) — skip save, keep local state only.
+ if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
+ return preferences;
+
response.EnsureSuccessStatusCode();
var saved = await response.Content.ReadFromJsonAsync(_jsonOptions, ct)