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.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.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.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
|
### 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.
|
- `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.
|
- 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.
|
- 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
|
## 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.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.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.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.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.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. |
|
| `P1.7` | Pending | Skip link and landmark work will land with the shell markup. |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
|
<script src="@Assets["theme.js"]"></script>
|
||||||
<script src="@Assets["tables.js"]"></script>
|
<script src="@Assets["tables.js"]"></script>
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
@inject RolemasterDb.App.Frontend.AppState.ThemeState ThemeState
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
@@ -17,3 +19,25 @@
|
|||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</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;
|
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 ThemeMode CurrentMode { get; private set; } = ThemeMode.System;
|
||||||
|
|
||||||
public event Action? Changed;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrentMode = mode;
|
CurrentMode = mode;
|
||||||
|
await ApplyThemeAsync(mode);
|
||||||
|
await browserStorage.SetItemAsync(BrowserStorageKeys.ThemeMode, ToStorageValue(mode));
|
||||||
|
isInitialized = true;
|
||||||
Changed?.Invoke();
|
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.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
|
||||||
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
|
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
|
||||||
builder.Services.AddScoped<LookupService>();
|
builder.Services.AddScoped<LookupService>();
|
||||||
|
builder.Services.AddScoped<BrowserStorageService>();
|
||||||
builder.Services.AddScoped<ThemeState>();
|
builder.Services.AddScoped<ThemeState>();
|
||||||
|
|
||||||
var app = builder.Build();
|
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