Persist frontend theme preference
This commit is contained in:
@@ -41,6 +41,7 @@ It is intentionally implementation-focused:
|
||||
| 2026-03-21 | P1.1 | Completed | Replaced the legacy token root with semantic background, surface, text, border, focus, shadow, and semantic accent ramps while keeping compatibility aliases for incremental migration. |
|
||||
| 2026-03-21 | P1.2 | Completed | Switched the app to Fraunces, IBM Plex Sans, and IBM Plex Mono with distinct display, body, UI, and code font roles instead of one shared heading font. |
|
||||
| 2026-03-21 | P1.3 | Completed | Added explicit light, dark, and system theme modes in the token layer and introduced a scoped `ThemeState` service for later shell controls. |
|
||||
| 2026-03-21 | P1.4 | Completed | Added shared browser-storage wrappers, persisted theme mode in `localStorage`, and initialize/apply theme state from the layout on interactive render. |
|
||||
|
||||
### Lessons Learned
|
||||
|
||||
@@ -51,6 +52,7 @@ It is intentionally implementation-focused:
|
||||
- `localStorage` access is currently page-local and ad hoc. Theme, recents, pins, and table context need one shared storage boundary before more UI work starts.
|
||||
- The old typography setup coupled display and utility text under a single token. The new shell work needs separate display and UI font roles to avoid decorative type in controls.
|
||||
- Theme mode selection can be prepared independently of persistence. Splitting those concerns keeps the theme CSS and the storage wiring reviewable.
|
||||
- Shared UI state events in Blazor should stay synchronous unless the event contract is async-aware. Layout refresh can be triggered safely with `InvokeAsync(StateHasChanged)` from a synchronous handler.
|
||||
|
||||
## Target Outcomes
|
||||
|
||||
@@ -255,7 +257,7 @@ Create the implementation foundation so the visual overhaul does not start with
|
||||
| `P1.1` | Completed | Semantic token layer landed in `wwwroot/app.css` with compatibility aliases to keep existing pages stable. |
|
||||
| `P1.2` | Completed | Font loading now uses Fraunces, IBM Plex Sans, and IBM Plex Mono with explicit role-based tokens. |
|
||||
| `P1.3` | Completed | Explicit light, dark, and system modes now exist in CSS, backed by a scoped `ThemeState` service. |
|
||||
| `P1.4` | Pending | Theme persistence depends on the theme state service. |
|
||||
| `P1.4` | Completed | Theme mode now persists through a shared storage service and is applied from the layout during interactive startup. |
|
||||
| `P1.5` | Pending | Shell replacement follows once tokens and theme plumbing exist. |
|
||||
| `P1.6` | Pending | Shell slots and nav utilities depend on the new shell. |
|
||||
| `P1.7` | Pending | Skip link and landmark work will land with the shell markup. |
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["theme.js"]"></script>
|
||||
<script src="@Assets["tables.js"]"></script>
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject RolemasterDb.App.Frontend.AppState.ThemeState ThemeState
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
@@ -17,3 +19,25 @@
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ThemeState.Changed += HandleThemeChanged;
|
||||
}
|
||||
|
||||
protected override Task OnAfterRenderAsync(bool firstRender) =>
|
||||
firstRender
|
||||
? ThemeState.InitializeAsync()
|
||||
: Task.CompletedTask;
|
||||
|
||||
private void HandleThemeChanged()
|
||||
{
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ThemeState.Changed -= HandleThemeChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace RolemasterDb.App.Frontend.AppState;
|
||||
|
||||
public static class BrowserStorageKeys
|
||||
{
|
||||
public const string ThemeMode = "rolemaster.theme.mode";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RolemasterDb.App.Frontend.AppState;
|
||||
|
||||
public sealed class BrowserStorageService(IJSRuntime jsRuntime)
|
||||
{
|
||||
public ValueTask<string?> GetItemAsync(string key) =>
|
||||
jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
|
||||
public ValueTask SetItemAsync(string key, string value) =>
|
||||
jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
|
||||
}
|
||||
@@ -1,19 +1,66 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RolemasterDb.App.Frontend.AppState;
|
||||
|
||||
public sealed class ThemeState
|
||||
public sealed class ThemeState(BrowserStorageService browserStorage, IJSRuntime jsRuntime)
|
||||
{
|
||||
private bool isInitialized;
|
||||
|
||||
public ThemeMode CurrentMode { get; private set; } = ThemeMode.System;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public void SetMode(ThemeMode mode)
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (CurrentMode == mode)
|
||||
if (isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var storedMode = await browserStorage.GetItemAsync(BrowserStorageKeys.ThemeMode);
|
||||
CurrentMode = ParseMode(storedMode);
|
||||
await ApplyThemeAsync(CurrentMode);
|
||||
isInitialized = true;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// JS interop is unavailable during prerender. Retry on the next interactive render.
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetModeAsync(ThemeMode mode)
|
||||
{
|
||||
if (isInitialized && CurrentMode == mode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentMode = mode;
|
||||
await ApplyThemeAsync(mode);
|
||||
await browserStorage.SetItemAsync(BrowserStorageKeys.ThemeMode, ToStorageValue(mode));
|
||||
isInitialized = true;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private Task ApplyThemeAsync(ThemeMode mode) =>
|
||||
jsRuntime.InvokeVoidAsync("rolemasterTheme.apply", ToStorageValue(mode)).AsTask();
|
||||
|
||||
private static ThemeMode ParseMode(string? value) =>
|
||||
value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"light" => ThemeMode.Light,
|
||||
"dark" => ThemeMode.Dark,
|
||||
_ => ThemeMode.System
|
||||
};
|
||||
|
||||
private static string ToStorageValue(ThemeMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
ThemeMode.Light => "light",
|
||||
ThemeMode.Dark => "dark",
|
||||
_ => "system"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ builder.Services.AddRazorComponents()
|
||||
builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
|
||||
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
|
||||
builder.Services.AddScoped<LookupService>();
|
||||
builder.Services.AddScoped<BrowserStorageService>();
|
||||
builder.Services.AddScoped<ThemeState>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
5
src/RolemasterDb.App/wwwroot/theme.js
Normal file
5
src/RolemasterDb.App/wwwroot/theme.js
Normal file
@@ -0,0 +1,5 @@
|
||||
window.rolemasterTheme = {
|
||||
apply(mode) {
|
||||
document.documentElement.dataset.theme = mode || "system";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user