style: complete Light Sepia theme overrides for user dashboard (#78)

Resolves #79

This Pull Request completes the visual implementation of the premium **"Warm Paper / Soft Sepia"** light theme across all user dashboard sub-modules and components.

### 🎨 Styling Refactoring & Light Sepia Layout
We refactored isolated component styles (`.razor.css`) to ensure proper contrast, remove hardcoded dark tokens, and fix text visibility under the `.theme-light` environment:

1. **Dashboard & Sidebar Layouts:**
   * **MainHubLayout.razor.css** & **Dashboard.razor.css**: Replaced hardcoded dark backgrounds with `var(--bg-surface)` and `var(--bg-base)`. Overrode user name brackets, progress bar elements, active quiz cards, graph nodes, and buttons.
2. **Catalog & Library Pages:**
   * **Catalog.razor.css** & **MyBooks.razor.css**: Adjusted cover hover actions, overlay transparency, and progress tracks. Fixed course tile background gradients to use a warm, elegant `#e4e1d9` layer and `var(--text-main)` code text.
3. **Profile & Settings Views:**
   * **Profile.razor.css** & **Settings.razor.css**: Overrode token usage progress tracks, page title gradient transparency, section descriptions, and diagnostic button styling.
4. **Concepts Dashboard & Interactive Widgets:**
   * **ConceptsDashboard.razor.css** & **ConceptsMap.razor.css**: Transitioned node headers, unlocked/locked badges, warning blocks, and term pills to semantic colors. Removed neon glow animations for locked nodes.
5. **Intelligence Workspace & AI Responses:**
   * **Intelligence.razor.css** & **AiResponseRenderer.razor.css**: Refactored empty state welcome messages, placeholder styles, input fields, and robot avatar borders. Refined the linear-gradient mask fade effect to blend correctly into the light sepia surface environment rather than dropping into dark transparent channels.
6. **Dashboard Sidebar Widgets:**
   * **CurrentReadingWidget.razor.css** & **ContextualRecommendationsWidget.razor.css**: Replaced hardcoded `#1a1a1e` card containers and light text colors with semantic variables (`var(--bg-surface)`, `var(--border)`, `var(--text-main)`, and `var(--text-muted)`).

### 🛠️ Blazor CSS Isolation Compiler Compliance
* Avoided the use of `:global(.theme-light)` selector overrides within isolated CSS rules because they are unsupported by Blazor's CSS isolation compiler and cause the compiled stylesheet to ignore them.
* Replaced them with the correct standard selector format `.theme-light .some-class` which properly compiles to `.theme-light .some-class[b-xxxx]`, applying correct theme styles recursively to child scoped markup.

### 🧪 Verification
* Checked that the solution builds cleanly with 0 compiler errors: `dotnet build NexusReader.slnx --no-restore`
* Ran all unit tests successfully: `dotnet test NexusReader.slnx --no-restore`

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #78
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #78.
This commit is contained in:
2026-06-07 16:56:36 +00:00
committed by Marek Jaisński
parent 1d6862016d
commit 9291bde531
51 changed files with 2574 additions and 367 deletions
@@ -1,9 +1,14 @@
using NexusReader.Domain.Enums;
namespace NexusReader.UI.Shared.Services;
public interface IThemeService
{
ThemeMode Mode { get; }
bool IsLightMode { get; }
event Func<Task>? OnThemeChanged;
event Action<ThemeMode>? OnThemeChanged;
Task InitializeAsync();
Task SetThemeAsync(ThemeMode mode);
Task ToggleTheme();
}
@@ -1,42 +1,155 @@
using Microsoft.JSInterop;
using NexusReader.Domain.Enums;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.UI.Shared.Services;
public sealed class ThemeService : IThemeService
{
private readonly IJSRuntime _jsRuntime;
public bool IsLightMode { get; private set; } = false;
public event Func<Task>? OnThemeChanged;
private readonly IUserPreferenceStore _userPreferenceStore;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private bool _isInitialized;
private bool _systemPrefersLight;
public ThemeService(IJSRuntime jsRuntime)
public ThemeMode Mode { get; private set; } = ThemeMode.System;
public bool IsLightMode => Mode == ThemeMode.LightSepia || (Mode == ThemeMode.System && _systemPrefersLight);
public event Action<ThemeMode>? OnThemeChanged;
public ThemeService(IJSRuntime jsRuntime, IUserPreferenceStore userPreferenceStore)
{
_jsRuntime = jsRuntime;
_userPreferenceStore = userPreferenceStore;
}
public async Task InitializeAsync()
{
if (_isInitialized) return;
await _semaphore.WaitAsync();
try
{
IsLightMode = await _jsRuntime.InvokeAsync<bool>("themeInterop.isLightMode");
if (OnThemeChanged != null) await OnThemeChanged();
if (_isInitialized) return;
ThemeMode localMode = ThemeMode.System;
try
{
var cachedThemeVal = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "theme-mode");
if (Enum.TryParse<ThemeMode>(cachedThemeVal, out var parsedMode))
{
localMode = parsedMode;
}
else if (cachedThemeVal == "light" || cachedThemeVal == "theme-light")
{
localMode = ThemeMode.LightSepia;
}
else if (cachedThemeVal == "dark" || cachedThemeVal == "theme-dark")
{
localMode = ThemeMode.Dark;
}
_systemPrefersLight = await _jsRuntime.InvokeAsync<bool>("themeInterop.isSystemLight");
}
catch
{
// Silent catch for pre-rendering or unit tests
}
Mode = localMode;
_isInitialized = true;
await ApplyThemeToDomAsync(Mode);
// Asynchronously sync with the cloud to check for updates from other devices
_ = Task.Run(async () =>
{
try
{
var cloudResult = await _userPreferenceStore.GetThemePreferenceAsync();
if (cloudResult.IsSuccess && cloudResult.Value != Mode)
{
await SetThemeInternalAsync(cloudResult.Value, saveToCloud: false);
}
}
catch
{
// Fail silently for background task/network errors
}
});
}
catch
finally
{
// Fail silently during prerendering or if JS is not available yet
_semaphore.Release();
}
}
public async Task SetThemeAsync(ThemeMode mode)
{
await SetThemeInternalAsync(mode, saveToCloud: true);
}
private async Task SetThemeInternalAsync(ThemeMode mode, bool saveToCloud)
{
await _semaphore.WaitAsync();
try
{
if (Mode == mode && _isInitialized) return;
Mode = mode;
_isInitialized = true;
await ApplyThemeToDomAsync(mode);
OnThemeChanged?.Invoke(mode);
if (saveToCloud)
{
_ = Task.Run(async () =>
{
try
{
await _userPreferenceStore.SaveThemePreferenceAsync(mode);
}
catch
{
// Fail silently for background cloud sync errors
}
});
}
}
finally
{
_semaphore.Release();
}
}
public async Task ToggleTheme()
{
IsLightMode = !IsLightMode;
var nextMode = IsLightMode ? ThemeMode.Dark : ThemeMode.LightSepia;
await SetThemeAsync(nextMode);
}
private async Task ApplyThemeToDomAsync(ThemeMode mode)
{
try
{
await _jsRuntime.InvokeVoidAsync("themeInterop.setLightMode", IsLightMode);
string themeClass = "theme-dark"; // Default
if (mode == ThemeMode.LightSepia)
{
themeClass = "theme-light";
}
else if (mode == ThemeMode.System)
{
themeClass = _systemPrefersLight ? "theme-light" : "theme-dark";
}
await _jsRuntime.InvokeVoidAsync("themeInterop.setCachedTheme", themeClass, ((int)mode).ToString());
}
catch
{
// Fail silently
// Silent catch for pre-rendering
}
if (OnThemeChanged != null) await OnThemeChanged();
}
}