diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 2d1f74d..98a889b 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -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. | diff --git a/src/RolemasterDb.App/Components/App.razor b/src/RolemasterDb.App/Components/App.razor index a6c6d18..b22efe8 100644 --- a/src/RolemasterDb.App/Components/App.razor +++ b/src/RolemasterDb.App/Components/App.razor @@ -20,6 +20,7 @@ + diff --git a/src/RolemasterDb.App/Components/Layout/MainLayout.razor b/src/RolemasterDb.App/Components/Layout/MainLayout.razor index 15ad733..c0a9d0c 100644 --- a/src/RolemasterDb.App/Components/Layout/MainLayout.razor +++ b/src/RolemasterDb.App/Components/Layout/MainLayout.razor @@ -1,4 +1,6 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase +@implements IDisposable +@inject RolemasterDb.App.Frontend.AppState.ThemeState ThemeState
+ +@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; + } +} diff --git a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs new file mode 100644 index 0000000..eda8783 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageKeys.cs @@ -0,0 +1,6 @@ +namespace RolemasterDb.App.Frontend.AppState; + +public static class BrowserStorageKeys +{ + public const string ThemeMode = "rolemaster.theme.mode"; +} diff --git a/src/RolemasterDb.App/Frontend/AppState/BrowserStorageService.cs b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageService.cs new file mode 100644 index 0000000..90664c9 --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/BrowserStorageService.cs @@ -0,0 +1,12 @@ +using Microsoft.JSInterop; + +namespace RolemasterDb.App.Frontend.AppState; + +public sealed class BrowserStorageService(IJSRuntime jsRuntime) +{ + public ValueTask GetItemAsync(string key) => + jsRuntime.InvokeAsync("localStorage.getItem", key); + + public ValueTask SetItemAsync(string key, string value) => + jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value); +} diff --git a/src/RolemasterDb.App/Frontend/AppState/ThemeState.cs b/src/RolemasterDb.App/Frontend/AppState/ThemeState.cs index 424e456..76cd695 100644 --- a/src/RolemasterDb.App/Frontend/AppState/ThemeState.cs +++ b/src/RolemasterDb.App/Frontend/AppState/ThemeState.cs @@ -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" + }; } diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index bb51e62..b4c6d35 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddRazorComponents() builder.Services.AddDbContextFactory(options => options.UseSqlite(connectionString)); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); var app = builder.Build(); diff --git a/src/RolemasterDb.App/wwwroot/theme.js b/src/RolemasterDb.App/wwwroot/theme.js new file mode 100644 index 0000000..484ca30 --- /dev/null +++ b/src/RolemasterDb.App/wwwroot/theme.js @@ -0,0 +1,5 @@ +window.rolemasterTheme = { + apply(mode) { + document.documentElement.dataset.theme = mode || "system"; + } +};