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";
+ }
+};