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
+22 -7
View File
@@ -11,13 +11,28 @@
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isLight = savedTheme === 'light' || (!savedTheme && !systemPrefersDark);
if (isLight) {
document.documentElement.classList.add('theme-light');
} else {
document.documentElement.classList.remove('theme-light');
try {
var themeMode = localStorage.getItem('theme-mode');
var savedTheme = localStorage.getItem('theme');
var isLight = false;
if (themeMode === '2' || savedTheme === 'light') {
isLight = true;
} else if (themeMode === '1' || savedTheme === 'dark') {
isLight = false;
} else {
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
}
if (isLight) {
document.documentElement.classList.add('theme-light');
document.documentElement.classList.remove('theme-dark');
} else {
document.documentElement.classList.add('theme-dark');
document.documentElement.classList.remove('theme-light');
}
} catch (e) {
// Fail silently
}
})();
</script>
+17 -1
View File
@@ -52,7 +52,9 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IUserPreferenceStore, NexusReader.Web.Services.ServerUserPreferenceStore>();
builder.Services.AddScoped<IThemeService, NexusReader.Web.Services.ServerThemeService>();
builder.Services.AddScoped<IRecommendationService, NexusReader.Web.Services.ServerRecommendationService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings);
@@ -753,6 +755,20 @@ app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator)
return Results.Ok(result.Value);
}).RequireAuthorization();
app.MapPost("/identity/theme", async (
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.User.UpdateThemeRequest request,
ClaimsPrincipal user,
IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var result = await mediator.Send(new NexusReader.Application.Commands.User.UpdateThemeCommand(userId, request.Mode));
if (result.IsFailed) return Results.BadRequest(result.Errors.FirstOrDefault()?.Message);
return Results.Ok();
}).RequireAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.AspNetCore.Http;
using NexusReader.Application.Queries.Recommendations;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Services;
/// <summary>
/// Server-side implementation of <see cref="IRecommendationService"/> that executes
/// the MediatR query directly inside the Web Server's request context.
/// </summary>
public sealed class ServerRecommendationService : IRecommendationService
{
private readonly IMediator _mediator;
private readonly IHttpContextAccessor _httpContextAccessor;
public ServerRecommendationService(IMediator mediator, IHttpContextAccessor httpContextAccessor)
{
_mediator = mediator;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User == null)
{
return new List<RecommendationDto>();
}
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return new List<RecommendationDto>();
}
var result = await _mediator.Send(new GetContextualRecommendationsQuery(userId), cancellationToken);
if (result.IsSuccess && result.Value != null)
{
return result.Value.Recommendations;
}
return new List<RecommendationDto>();
}
}
@@ -0,0 +1,21 @@
using NexusReader.Domain.Enums;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Services;
public sealed class ServerThemeService : IThemeService
{
public ThemeMode Mode => ThemeMode.System;
public bool IsLightMode => false;
// Explicit event implementation to avoid CS0067 warning about unused events on the server
public event Action<ThemeMode>? OnThemeChanged
{
add { }
remove { }
}
public Task InitializeAsync() => Task.CompletedTask;
public Task SetThemeAsync(ThemeMode mode) => Task.CompletedTask;
public Task ToggleTheme() => Task.CompletedTask;
}
@@ -0,0 +1,78 @@
using System.Security.Claims;
using FluentResults;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Enums;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Services;
public class ServerUserPreferenceStore : IUserPreferenceStore
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly IHttpContextAccessor _httpContextAccessor;
public ServerUserPreferenceStore(
IDbContextFactory<AppDbContext> dbContextFactory,
IHttpContextAccessor httpContextAccessor)
{
_dbContextFactory = dbContextFactory;
_httpContextAccessor = httpContextAccessor;
}
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
{
var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return Result.Fail("User is not authenticated on the server.");
}
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var user = await dbContext.Users
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
{
return Result.Fail("User not found in database.");
}
user.ThemePreference = mode;
await dbContext.SaveChangesAsync();
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Database failure updating theme preference.").CausedBy(ex));
}
}
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
{
var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return Result.Fail("User is not authenticated on the server.");
}
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
{
return Result.Fail("User not found in database.");
}
return Result.Ok(user.ThemePreference);
}
catch (Exception ex)
{
return Result.Fail(new Error("Database failure reading theme preference.").CausedBy(ex));
}
}
}